Compare commits
2
Commits
d587dc3f20
...
dc11961805
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc11961805 | ||
|
|
f627918729 |
@@ -113,6 +113,9 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
|
||||||
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
|
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 TelegramClientManager? TelegramClient { get; private init; }
|
public TelegramClientManager? TelegramClient { get; private init; }
|
||||||
public OpenRouterStatusService? Status { get; private init; }
|
public OpenRouterStatusService? Status { get; private init; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using ClawdDotNet.Core.Accounting;
|
||||||
|
using ClawdDotNet.Core.Activity;
|
||||||
|
using ClawdDotNet.Core.Audit;
|
||||||
|
using ClawdDotNet.Core.Config;
|
||||||
|
using ClawdDotNet.Core.Engine;
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// #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)
|
||||||
|
{
|
||||||
|
/// <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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TeamSnapshot(members, links, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Activity;
|
||||||
|
|
||||||
|
/// <summary>A run that is in progress right now.</summary>
|
||||||
|
public sealed record ActiveRun(
|
||||||
|
string RunId,
|
||||||
|
string AgentId,
|
||||||
|
string Source,
|
||||||
|
string? TaskId,
|
||||||
|
string? CalledBy,
|
||||||
|
DateTime StartedAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
|
||||||
|
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
|
||||||
|
///
|
||||||
|
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
|
||||||
|
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
|
||||||
|
/// <c>IsRunning</c>. And nothing recorded that agent A had called agent B — the delegation
|
||||||
|
/// chain knew it for the length of the call and then forgot. Here both are kept and
|
||||||
|
/// announced, independent of which engine path a run takes.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
|
||||||
|
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AgentActivity
|
||||||
|
{
|
||||||
|
/// <summary>How many recent links are kept for a page that connects late.</summary>
|
||||||
|
public const int RecentLinkCapacity = 500;
|
||||||
|
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
|
||||||
|
private readonly Queue<AgentLink> _recentLinks = new();
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
|
||||||
|
|
||||||
|
public event Action<ActiveRun>? RunStarted;
|
||||||
|
|
||||||
|
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
|
||||||
|
public event Action<ActiveRun, string>? RunFinished;
|
||||||
|
|
||||||
|
public event Action<AgentLink>? LinkObserved;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
|
||||||
|
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
|
||||||
|
/// also records the link from the caller.
|
||||||
|
/// </summary>
|
||||||
|
public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
|
||||||
|
var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
|
||||||
|
|
||||||
|
AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_active[runId] = run;
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
_recentLinks.Enqueue(link);
|
||||||
|
while (_recentLinks.Count > RecentLinkCapacity)
|
||||||
|
_recentLinks.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise(() => RunStarted?.Invoke(run));
|
||||||
|
if (link is not null)
|
||||||
|
Raise(() => LinkObserved?.Invoke(link));
|
||||||
|
|
||||||
|
return new RunHandle(this, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The runs in progress right now.</summary>
|
||||||
|
public IReadOnlyList<ActiveRun> ActiveRuns()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
return [.. _active.Values];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
|
||||||
|
public IReadOnlyList<AgentLink> RecentLinks(TimeSpan window)
|
||||||
|
{
|
||||||
|
var since = _clock.GetUtcNow().UtcDateTime - window;
|
||||||
|
lock (_lock)
|
||||||
|
return [.. _recentLinks.Where(l => l.AtUtc >= since)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void End(ActiveRun run, string status)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (!_active.Remove(run.RunId))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Raise(() => RunFinished?.Invoke(run, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
|
||||||
|
private static void Raise(Action raise)
|
||||||
|
{
|
||||||
|
try { raise(); }
|
||||||
|
catch { /* observers are best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RunHandle : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AgentActivity _owner;
|
||||||
|
private readonly ActiveRun _run;
|
||||||
|
private string _status = "Aborted";
|
||||||
|
|
||||||
|
internal RunHandle(AgentActivity owner, ActiveRun run)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_run = run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
|
||||||
|
public void Complete(string status) => _status = status;
|
||||||
|
|
||||||
|
public void Dispose() => _owner.End(_run, _status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public BudgetGuard? BudgetGuard => _budgetGuard;
|
public BudgetGuard? BudgetGuard => _budgetGuard;
|
||||||
|
|
||||||
|
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
|
||||||
|
public Activity.AgentActivity Activity { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
|
||||||
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
|
||||||
@@ -179,8 +182,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
|
|
||||||
var runId = Guid.NewGuid().ToString("N");
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
|
||||||
|
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||||
var recorder = CreateRecorder(agentConfig);
|
var recorder = CreateRecorder(agentConfig);
|
||||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
|
||||||
|
activity.Complete(result.Status.ToString());
|
||||||
await RecordUsageAsync(agentConfig, result);
|
await RecordUsageAsync(agentConfig, result);
|
||||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||||
@@ -498,8 +503,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// #711: "working" starts past the gate — waiting for it is not working.
|
||||||
|
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
|
||||||
var recorder = CreateRecorder(agentConfig);
|
var recorder = CreateRecorder(agentConfig);
|
||||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
|
||||||
|
activity.Complete(result.Status.ToString());
|
||||||
await RecordUsageAsync(agentConfig, result);
|
await RecordUsageAsync(agentConfig, result);
|
||||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||||
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||||
|
|||||||
@@ -1721,3 +1721,77 @@ div.klein,
|
|||||||
td.klein {
|
td.klein {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Team overview (#711) ─── */
|
||||||
|
|
||||||
|
.team-ansicht {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-zusammenfassung {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-flaeche {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-graph {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 420px;
|
||||||
|
background: var(--surface-subtil);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-seitenleiste {
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-seitenleiste-kopf {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-seitenleiste h3 {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-ergebnis {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--schrift-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-aktionen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-legende {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.team-flaeche { flex-direction: column; }
|
||||||
|
.team-seitenleiste { width: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import { renderUsageRuns } from "./views/usage-runs.js";
|
|||||||
import { renderUsageTimeline } from "./views/usage-timeline.js";
|
import { renderUsageTimeline } from "./views/usage-timeline.js";
|
||||||
import { renderRunDetail } from "./views/run-detail.js";
|
import { renderRunDetail } from "./views/run-detail.js";
|
||||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||||
|
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||||
|
|
||||||
// ─── 1. Routen registrieren ───
|
// ─── 1. Routen registrieren ───
|
||||||
|
|
||||||
function verlasseAnsicht() {
|
function verlasseAnsicht() {
|
||||||
|
leaveTeam();
|
||||||
trenneLogStream();
|
trenneLogStream();
|
||||||
trenneAgentenChatsStream();
|
trenneAgentenChatsStream();
|
||||||
stoppeInstanzenPolling();
|
stoppeInstanzenPolling();
|
||||||
@@ -50,6 +52,7 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
|
|||||||
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
|
||||||
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
|
||||||
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
|
||||||
|
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||||
|
|||||||
@@ -67,6 +67,28 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agenten",
|
"nav.agents": "Agenten",
|
||||||
"nav.tasks": "Aufgaben",
|
"nav.tasks": "Aufgaben",
|
||||||
|
"nav.team": "Teamübersicht",
|
||||||
|
"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.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)",
|
||||||
|
"team.state.Idle": "bereit",
|
||||||
|
"team.state.Working": "arbeitet",
|
||||||
|
"team.state.Stuck": "hängt",
|
||||||
|
"team.state.WaitingForApproval": "wartet auf Freigabe",
|
||||||
|
"team.state.Problem": "Problem",
|
||||||
|
"team.state.BudgetExhausted": "Budget erschöpft",
|
||||||
|
"team.running": "Läuft gerade",
|
||||||
|
"team.since": "seit {time}",
|
||||||
|
"team.task": "Aufgabe {id}",
|
||||||
|
"team.calledBy": "gerufen von {agent}",
|
||||||
|
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
|
||||||
|
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
|
||||||
|
"team.lastRun": "Letzter Lauf",
|
||||||
|
"team.openApprovals": "Freigaben öffnen",
|
||||||
|
"team.openChat": "Chat öffnen",
|
||||||
|
"team.openRuns": "Läufe anzeigen",
|
||||||
"nav.services": "Dienste",
|
"nav.services": "Dienste",
|
||||||
"nav.group.analytics": "Analyse",
|
"nav.group.analytics": "Analyse",
|
||||||
"nav.usage": "Tokennutzung",
|
"nav.usage": "Tokennutzung",
|
||||||
|
|||||||
@@ -67,6 +67,28 @@
|
|||||||
"nav.chat": "Chat",
|
"nav.chat": "Chat",
|
||||||
"nav.agents": "Agents",
|
"nav.agents": "Agents",
|
||||||
"nav.tasks": "Tasks",
|
"nav.tasks": "Tasks",
|
||||||
|
"nav.team": "Team overview",
|
||||||
|
"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.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)",
|
||||||
|
"team.state.Idle": "idle",
|
||||||
|
"team.state.Working": "working",
|
||||||
|
"team.state.Stuck": "stuck",
|
||||||
|
"team.state.WaitingForApproval": "waiting for approval",
|
||||||
|
"team.state.Problem": "problem",
|
||||||
|
"team.state.BudgetExhausted": "budget exhausted",
|
||||||
|
"team.running": "Running now",
|
||||||
|
"team.since": "since {time}",
|
||||||
|
"team.task": "task {id}",
|
||||||
|
"team.calledBy": "called by {agent}",
|
||||||
|
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
|
||||||
|
"team.approvals": "{n} tool call(s) waiting for approval",
|
||||||
|
"team.lastRun": "Last run",
|
||||||
|
"team.openApprovals": "Open approvals",
|
||||||
|
"team.openChat": "Open chat",
|
||||||
|
"team.openRuns": "Show runs",
|
||||||
"nav.services": "Services",
|
"nav.services": "Services",
|
||||||
"nav.group.analytics": "Analytics",
|
"nav.group.analytics": "Analytics",
|
||||||
"nav.usage": "Token Usage",
|
"nav.usage": "Token Usage",
|
||||||
|
|||||||
@@ -55,6 +55,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
<span class="nav-label" data-i18n="nav.agents">Agents</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
|
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label" data-i18n="nav.team">Team overview</span>
|
||||||
|
</a>
|
||||||
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
|
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||||
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function verbindeStrom() {
|
|||||||
meldeStatus(false);
|
meldeStatus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
|
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
|
||||||
for (const art of bekannteEreignisse) {
|
for (const art of bekannteEreignisse) {
|
||||||
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
// - A click opens a side panel, not the chat — most problems are solved by an approval.
|
||||||
|
|
||||||
|
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||||
|
import { erstelleElement, erstellePlakette, formatiereDatumZeit } from "../modules/dom.js";
|
||||||
|
import { abonnieren } from "../modules/events.js";
|
||||||
|
import { t } from "../modules/i18n.js";
|
||||||
|
|
||||||
|
// Loaded on demand: pages without a graph do not pay for ~2 MB of script.
|
||||||
|
const LIBRARIES = [
|
||||||
|
"vendor/cytoscape/cytoscape.min.js",
|
||||||
|
"vendor/elkjs/elk.bundled.js",
|
||||||
|
"vendor/cytoscape-elk/cytoscape-elk.js"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** How long a link stays visible after it was observed; it fades out over this time. */
|
||||||
|
const LINK_VISIBLE_MS = 30_000;
|
||||||
|
|
||||||
|
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
|
||||||
|
const REFRESH_MS = 30_000;
|
||||||
|
|
||||||
|
// Every state has a colour AND a symbol: colour alone is not enough.
|
||||||
|
const STATES = {
|
||||||
|
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
|
||||||
|
Working: { symbol: "▶", tone: "erfolg", colour: "--accent" },
|
||||||
|
Stuck: { symbol: "⧗", tone: "gefahr", colour: "--gefahr" },
|
||||||
|
WaitingForApproval: { symbol: "✋", tone: "warnung", colour: "--warnung" },
|
||||||
|
Problem: { symbol: "⚠", tone: "gefahr", colour: "--gefahr" },
|
||||||
|
BudgetExhausted: { symbol: "$", tone: "warnung", colour: "--warnung" }
|
||||||
|
};
|
||||||
|
|
||||||
|
let librariesLoaded = null;
|
||||||
|
let cy = null;
|
||||||
|
let snapshot = null;
|
||||||
|
let selectedAgent = null;
|
||||||
|
let panel = null;
|
||||||
|
let summary = null;
|
||||||
|
let unsubscribe = null;
|
||||||
|
let refreshTimer = null;
|
||||||
|
let fadeTimer = null;
|
||||||
|
let refreshPending = null;
|
||||||
|
let resizeObserver = null;
|
||||||
|
|
||||||
|
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
|
||||||
|
let layoutWidth = 0;
|
||||||
|
|
||||||
|
// ─── Loading ───
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = src;
|
||||||
|
script.onload = resolve;
|
||||||
|
script.onerror = () => reject(new Error(`Could not load ${src}`));
|
||||||
|
document.head.append(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLibraries() {
|
||||||
|
librariesLoaded ??= (async () => {
|
||||||
|
// In order: cytoscape-elk registers against the globals of the two before it.
|
||||||
|
for (const src of LIBRARIES) await loadScript(src);
|
||||||
|
window.cytoscape.use(window.cytoscapeElk);
|
||||||
|
})();
|
||||||
|
return librariesLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(name) {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#888";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── View ───
|
||||||
|
|
||||||
|
export async function renderTeam() {
|
||||||
|
const container = document.getElementById("ansicht-inhalt");
|
||||||
|
if (!container) return;
|
||||||
|
leaveTeam();
|
||||||
|
|
||||||
|
const layout = erstelleElement("div", { klasse: "team-ansicht" });
|
||||||
|
layout.append(
|
||||||
|
erstelleElement("h1", { text: t("team.title") }),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft", text: t("team.subtitle") })
|
||||||
|
);
|
||||||
|
|
||||||
|
summary = erstelleElement("div", { klasse: "team-zusammenfassung gruppe-horizontal" });
|
||||||
|
const canvas = erstelleElement("div", { klasse: "team-graph", id: "team-graph" });
|
||||||
|
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
|
||||||
|
panel.hidden = true;
|
||||||
|
|
||||||
|
layout.append(summary, erstelleElement("div", { klasse: "team-flaeche" }, canvas, panel), buildLegend());
|
||||||
|
container.replaceChildren(layout);
|
||||||
|
|
||||||
|
if (!holeAktiveInstanz()) {
|
||||||
|
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadLibraries();
|
||||||
|
snapshot = await api("/team");
|
||||||
|
} catch (err) {
|
||||||
|
canvas.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.replaceChildren();
|
||||||
|
drawGraph(canvas);
|
||||||
|
|
||||||
|
unsubscribe = abonnieren("activity", onActivity);
|
||||||
|
refreshTimer = setInterval(refresh, REFRESH_MS);
|
||||||
|
fadeTimer = setInterval(fadeLinks, 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the route changes: stop listening, stop timers, free the graph. */
|
||||||
|
export function leaveTeam() {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
clearInterval(fadeTimer);
|
||||||
|
clearTimeout(refreshPending);
|
||||||
|
refreshTimer = fadeTimer = refreshPending = null;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
cy?.destroy();
|
||||||
|
cy = null;
|
||||||
|
selectedAgent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Graph ───
|
||||||
|
|
||||||
|
function drawGraph(canvas) {
|
||||||
|
cy = window.cytoscape({
|
||||||
|
container: canvas,
|
||||||
|
elements: [],
|
||||||
|
wheelSensitivity: 0.2,
|
||||||
|
minZoom: 0.3,
|
||||||
|
maxZoom: 2.5,
|
||||||
|
// Nodes are not dragged: the layout comes from the structure, not the hand.
|
||||||
|
autoungrabify: true,
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
selector: "node",
|
||||||
|
style: {
|
||||||
|
shape: "rectangle",
|
||||||
|
width: 190,
|
||||||
|
height: 58,
|
||||||
|
"background-color": token("--surface"),
|
||||||
|
"border-width": 2,
|
||||||
|
"border-color": "data(colour)",
|
||||||
|
label: "data(label)",
|
||||||
|
"text-wrap": "wrap",
|
||||||
|
"text-max-width": 176,
|
||||||
|
"text-valign": "center",
|
||||||
|
"text-halign": "center",
|
||||||
|
"font-family": "Barlow, sans-serif",
|
||||||
|
"font-size": 13,
|
||||||
|
color: token("--text")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ selector: "node.aktiv", style: { "border-width": 4 } },
|
||||||
|
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
|
||||||
|
{
|
||||||
|
selector: "edge",
|
||||||
|
style: {
|
||||||
|
width: 3,
|
||||||
|
"line-color": token("--accent"),
|
||||||
|
"target-arrow-color": token("--accent"),
|
||||||
|
"target-arrow-shape": "triangle",
|
||||||
|
"curve-style": "bezier",
|
||||||
|
opacity: "data(opacity)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.on("tap", "node", (event) => openPanel(event.target.id()));
|
||||||
|
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
|
||||||
|
|
||||||
|
applySnapshot(true);
|
||||||
|
for (const link of snapshot.links ?? []) addLink(link);
|
||||||
|
observeSize(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The area can still be 0 px wide when the graph is first drawn — the page is being laid out
|
||||||
|
* around it. ELK then packs everything into one column, and nothing can be fitted. So: lay
|
||||||
|
* out again once the area has a width, and fit again whenever its size changes (the side
|
||||||
|
* panel opening, the window resizing).
|
||||||
|
*/
|
||||||
|
function observeSize(canvas) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!cy) return;
|
||||||
|
cy.resize();
|
||||||
|
if (layoutWidth === 0 && canvas.clientWidth > 0) runLayout();
|
||||||
|
else cy.fit(undefined, 30);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runLayout() {
|
||||||
|
const area = cy.container();
|
||||||
|
layoutWidth = area.clientWidth;
|
||||||
|
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.
|
||||||
|
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 computes asynchronously; fit once the positions are there.
|
||||||
|
layout.one("layoutstop", () => cy?.fit(undefined, 30));
|
||||||
|
layout.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeLabel(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}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySnapshot(relayout) {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
cy.add({ group: "nodes", data });
|
||||||
|
relayout = true;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Live ───
|
||||||
|
|
||||||
|
function forActiveInstance(data) {
|
||||||
|
// The manager merges the streams of all instances and stamps each event.
|
||||||
|
return !data?.instanz || data.instanz === holeAktiveInstanz();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivity(data) {
|
||||||
|
if (!cy || !forActiveInstance(data)) return;
|
||||||
|
|
||||||
|
if (data.kind === "link" && data.link) addLink(data.link);
|
||||||
|
|
||||||
|
// Start and end change states; ask the instance instead of guessing here.
|
||||||
|
clearTimeout(refreshPending);
|
||||||
|
refreshPending = setTimeout(refresh, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (!cy) return;
|
||||||
|
try {
|
||||||
|
snapshot = await api("/team");
|
||||||
|
applySnapshot(false);
|
||||||
|
} 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 fadeLinks() {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Summary and legend ───
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
if (!summary || !snapshot) return;
|
||||||
|
const counts = {};
|
||||||
|
for (const member of snapshot.members) counts[member.state] = (counts[member.state] ?? 0) + 1;
|
||||||
|
|
||||||
|
const parts = Object.keys(STATES)
|
||||||
|
.filter((state) => counts[state])
|
||||||
|
.map((state) => erstellePlakette(`${STATES[state].symbol} ${counts[state]} ${t(`team.state.${state}`)}`, STATES[state].tone));
|
||||||
|
|
||||||
|
summary.replaceChildren(
|
||||||
|
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLegend() {
|
||||||
|
return erstelleElement(
|
||||||
|
"div",
|
||||||
|
{ 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.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();
|
||||||
|
|
||||||
|
const state = STATES[member.state] ?? STATES.Idle;
|
||||||
|
const rows = [
|
||||||
|
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
|
||||||
|
erstelleElement("h2", { text: member.displayName || member.agentId }),
|
||||||
|
erstelleElement("button", {
|
||||||
|
klasse: "schaltflaeche flach klein",
|
||||||
|
text: "×",
|
||||||
|
title: t("common.close"),
|
||||||
|
onclick: closePanel
|
||||||
|
})),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
|
||||||
|
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Top layer: what is happening now.
|
||||||
|
if (member.runs.length) {
|
||||||
|
rows.push(erstelleElement("h3", { text: t("team.running") }));
|
||||||
|
for (const run of member.runs) {
|
||||||
|
const parts = [
|
||||||
|
t("team.since", { time: formatiereDatumZeit(run.startedAtUtc) }),
|
||||||
|
run.source,
|
||||||
|
run.taskId ? t("team.task", { id: run.taskId }) : null,
|
||||||
|
run.calledBy ? t("team.calledBy", { agent: run.calledBy }) : null
|
||||||
|
].filter(Boolean);
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein", text: parts.join(" · ") }));
|
||||||
|
}
|
||||||
|
if (member.state === "Stuck" && member.typicalRunMs > 0) {
|
||||||
|
rows.push(erstelleElement("p", {
|
||||||
|
klasse: "text-klein",
|
||||||
|
text: `⚠ ${t("team.stuckHint", { minutes: Math.max(1, Math.round(member.typicalRunMs / 60_000)) })}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.pendingApprovals > 0) {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: member.pendingApprovals })}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle layer: how the last run ended.
|
||||||
|
rows.push(erstelleElement("h3", { text: t("team.lastRun") }));
|
||||||
|
if (member.lastRunStatus) {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein" },
|
||||||
|
erstellePlakette(member.lastRunStatus, member.lastRunStatus === "Completed" ? "erfolg" : "warnung"),
|
||||||
|
` ${formatiereDatumZeit(member.lastRunAtUtc)}`));
|
||||||
|
if (member.lastRunResult) {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "text-klein team-ergebnis", text: member.lastRunResult }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actions — the approval first, it is what most problems need.
|
||||||
|
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
|
||||||
|
if (member.pendingApprovals > 0) {
|
||||||
|
actions.append(link(`#/staging`, t("team.openApprovals"), "primaer"));
|
||||||
|
}
|
||||||
|
actions.append(
|
||||||
|
link(`#/chat/${encodeURIComponent(member.agentId)}`, t("team.openChat")),
|
||||||
|
// Bottom layer: every run with its raw transcript.
|
||||||
|
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
|
||||||
|
);
|
||||||
|
rows.push(actions);
|
||||||
|
|
||||||
|
panel.replaceChildren(...rows);
|
||||||
|
panel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function link(href, text, style = "flach") {
|
||||||
|
return erstelleElement("button", {
|
||||||
|
type: "button",
|
||||||
|
klasse: `${style} klein`,
|
||||||
|
text,
|
||||||
|
onclick: () => { window.location.hash = href; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
selectedAgent = null;
|
||||||
|
cy?.elements().unselect();
|
||||||
|
if (panel) panel.hidden = true;
|
||||||
|
}
|
||||||
@@ -125,6 +125,31 @@ public sealed record TaskDto(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
|
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: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
|
||||||
|
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TeamMemberDto(
|
||||||
|
string AgentId,
|
||||||
|
string DisplayName,
|
||||||
|
string Model,
|
||||||
|
string State,
|
||||||
|
IReadOnlyList<ActiveRunDto> Runs,
|
||||||
|
int PendingApprovals,
|
||||||
|
string? LastRunStatus,
|
||||||
|
DateTime? LastRunAtUtc,
|
||||||
|
string? LastRunResult,
|
||||||
|
long TypicalRunMs);
|
||||||
|
|
||||||
|
/// <summary>#711: A run in progress.</summary>
|
||||||
|
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
|
||||||
|
|
||||||
|
/// <summary>#711: One agent reaching another.</summary>
|
||||||
|
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
|
||||||
|
|
||||||
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
|
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
|
||||||
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
|
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ internal static class Mapping
|
|||||||
public static ChatEntryDto ToDto(this ChatEntry entry)
|
public static ChatEntryDto ToDto(this ChatEntry entry)
|
||||||
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
|
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
|
||||||
|
|
||||||
|
public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
|
||||||
|
=> new(
|
||||||
|
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(),
|
||||||
|
snapshot.RecentLinks.Select(ToDto).ToList(),
|
||||||
|
snapshot.AtUtc);
|
||||||
|
|
||||||
|
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||||
|
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||||
|
|
||||||
|
public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
|
||||||
|
=> new(link.From, link.To, link.Kind, link.AtUtc);
|
||||||
|
|
||||||
public static TaskDto ToDto(this TaskOverview overview)
|
public static TaskDto ToDto(this TaskOverview overview)
|
||||||
=> overview.Task.ToDto() with
|
=> overview.Task.ToDto() with
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
|
|||||||
api.MapUsage();
|
api.MapUsage();
|
||||||
api.MapAnalytics();
|
api.MapAnalytics();
|
||||||
api.MapLogs();
|
api.MapLogs();
|
||||||
|
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||||
|
|
||||||
app.MapEvents();
|
app.MapEvents();
|
||||||
app.MapControl();
|
app.MapControl();
|
||||||
|
|||||||
@@ -71,12 +71,23 @@ internal static class EventEndpoints
|
|||||||
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
|
||||||
=> Publish("run", new
|
=> Publish("run", new
|
||||||
{
|
{
|
||||||
|
agentId = result.AgentId,
|
||||||
model,
|
model,
|
||||||
status = result.Status.ToString(),
|
status = result.Status.ToString(),
|
||||||
steps = result.StepCount,
|
steps = result.StepCount,
|
||||||
tokens = result.TokensUsed
|
tokens = result.TokensUsed
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #711: the team overview — who starts, who stops, who reached whom.
|
||||||
|
void OnRunStarted(Core.Activity.ActiveRun run)
|
||||||
|
=> Publish("activity", new { kind = "started", run = run.ToDto() });
|
||||||
|
|
||||||
|
void OnRunFinished(Core.Activity.ActiveRun run, string status)
|
||||||
|
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
|
||||||
|
|
||||||
|
void OnLink(Core.Activity.AgentLink link)
|
||||||
|
=> Publish("activity", new { kind = "link", link = link.ToDto() });
|
||||||
|
|
||||||
void OnInstanceChanged(InstanceChange change)
|
void OnInstanceChanged(InstanceChange change)
|
||||||
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
|
||||||
|
|
||||||
@@ -88,6 +99,9 @@ internal static class EventEndpoints
|
|||||||
engine.OnChatEntryAdded += OnChat;
|
engine.OnChatEntryAdded += OnChat;
|
||||||
engine.OnStepCompleted += OnStep;
|
engine.OnStepCompleted += OnStep;
|
||||||
engine.OnRunCompleted += OnRunCompleted;
|
engine.OnRunCompleted += OnRunCompleted;
|
||||||
|
engine.Activity.RunStarted += OnRunStarted;
|
||||||
|
engine.Activity.RunFinished += OnRunFinished;
|
||||||
|
engine.Activity.LinkObserved += OnLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
host.InstanceChanged += OnInstanceChanged;
|
host.InstanceChanged += OnInstanceChanged;
|
||||||
@@ -106,6 +120,9 @@ internal static class EventEndpoints
|
|||||||
e.OnChatEntryAdded -= OnChat;
|
e.OnChatEntryAdded -= OnChat;
|
||||||
e.OnStepCompleted -= OnStep;
|
e.OnStepCompleted -= OnStep;
|
||||||
e.OnRunCompleted -= OnRunCompleted;
|
e.OnRunCompleted -= OnRunCompleted;
|
||||||
|
e.Activity.RunStarted -= OnRunStarted;
|
||||||
|
e.Activity.RunFinished -= OnRunFinished;
|
||||||
|
e.Activity.LinkObserved -= OnLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
host.InstanceChanged -= OnInstanceChanged;
|
host.InstanceChanged -= OnInstanceChanged;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using ClawdDotNet.App.Services;
|
||||||
|
using ClawdDotNet.Core.Activity;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.App.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
|
||||||
|
/// from a recorded fact, never from a model's judgement.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TeamOverviewTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static ActiveRun RunningFor(TimeSpan duration)
|
||||||
|
=> new("r", "worker", "task", null, null, Now - duration);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nothing_running_and_a_good_last_run_is_idle()
|
||||||
|
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_agent_without_any_run_yet_is_idle()
|
||||||
|
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_run_in_progress_is_working()
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
|
||||||
|
.ShouldBe(TeamState.Working);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_run_far_beyond_the_usual_duration_is_stuck()
|
||||||
|
// Usually 2 minutes: three times that is 6, running for 7.
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
|
||||||
|
.ShouldBe(TeamState.Stuck);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Short_runs_get_a_floor_before_they_count_as_stuck()
|
||||||
|
// Usually 20 seconds: three times is one minute, but the floor is five.
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
|
||||||
|
.ShouldBe(TeamState.Working);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Without_history_a_run_is_stuck_only_after_a_long_while()
|
||||||
|
{
|
||||||
|
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
|
||||||
|
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_open_approval_outranks_working()
|
||||||
|
// What the human can do something about comes first.
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||||
|
.ShouldBe(TeamState.WaitingForApproval);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stuck_outranks_an_open_approval()
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
|
||||||
|
.ShouldBe(TeamState.Stuck);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Failed")]
|
||||||
|
[InlineData("LoopLimitExceeded")]
|
||||||
|
[InlineData("TargetBusy")]
|
||||||
|
public void A_failed_last_run_is_a_problem(string status)
|
||||||
|
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_refused_last_run_shows_the_exhausted_budget()
|
||||||
|
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_new_run_hides_the_old_problem()
|
||||||
|
// A new run is the attempt to get past it; the problem shows again if it fails.
|
||||||
|
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
|
||||||
|
.ShouldBe(TeamState.Working);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using ClawdDotNet.Core.Activity;
|
||||||
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||||
|
using ClawdDotNet.Core.Tools;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Tests.Activity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #711: Who is working right now and who reached whom — the live side of the team overview.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AgentActivityTests
|
||||||
|
{
|
||||||
|
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_run_is_active_until_its_handle_is_disposed()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
|
||||||
|
using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
|
||||||
|
{
|
||||||
|
var active = activity.ActiveRuns().ShouldHaveSingleItem();
|
||||||
|
active.AgentId.ShouldBe("worker");
|
||||||
|
active.TaskId.ShouldBe("t-1");
|
||||||
|
active.CalledBy.ShouldBeNull();
|
||||||
|
run.Complete("Completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.ActiveRuns().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_and_end_are_announced_with_the_status()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
var log = new List<string>();
|
||||||
|
activity.RunStarted += r => log.Add($"start {r.AgentId}");
|
||||||
|
activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
|
||||||
|
|
||||||
|
using (var run = activity.Begin("r1", "worker", "chat", null, null))
|
||||||
|
run.Complete("LoopLimitExceeded");
|
||||||
|
using (activity.Begin("r2", "worker", "chat", null, null)) { }
|
||||||
|
|
||||||
|
log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_delegated_run_records_who_called_whom()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
AgentLink? seen = null;
|
||||||
|
activity.LinkObserved += l => seen = l;
|
||||||
|
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||||
|
|
||||||
|
using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
|
||||||
|
|
||||||
|
seen.ShouldNotBeNull();
|
||||||
|
seen.From.ShouldBe("lead");
|
||||||
|
seen.To.ShouldBe("buyer");
|
||||||
|
seen.Kind.ShouldBe("delegation");
|
||||||
|
activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Old_links_fall_out_of_the_recent_window()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
|
||||||
|
|
||||||
|
_clock.Advance(TimeSpan.FromMinutes(11));
|
||||||
|
|
||||||
|
activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void The_memory_of_links_is_bounded()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
var chain = DelegationChain.Root("lead").Append("buyer");
|
||||||
|
|
||||||
|
for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
|
||||||
|
using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
|
||||||
|
|
||||||
|
activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_subscriber_that_throws_does_not_break_the_run()
|
||||||
|
{
|
||||||
|
var activity = new AgentActivity(_clock);
|
||||||
|
activity.RunStarted += _ => throw new InvalidOperationException("broken page");
|
||||||
|
activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
|
||||||
|
|
||||||
|
Should.NotThrow(() =>
|
||||||
|
{
|
||||||
|
using var run = activity.Begin("r1", "worker", "chat", null, null);
|
||||||
|
run.Complete("Completed");
|
||||||
|
});
|
||||||
|
activity.ActiveRuns().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── through the engine ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task The_engine_announces_a_delegated_run_with_its_caller()
|
||||||
|
{
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
fixture.AddAgent("lead");
|
||||||
|
fixture.AddAgent("buyer");
|
||||||
|
fixture.Client.RespondsWithText("Erledigt.");
|
||||||
|
|
||||||
|
var started = new List<ActiveRun>();
|
||||||
|
var finished = new List<string>();
|
||||||
|
fixture.Engine.Activity.RunStarted += started.Add;
|
||||||
|
fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
|
||||||
|
|
||||||
|
var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
|
||||||
|
|
||||||
|
result.Delivered.ShouldBeTrue(result.Error);
|
||||||
|
var run = started.ShouldHaveSingleItem();
|
||||||
|
run.AgentId.ShouldBe("buyer");
|
||||||
|
run.CalledBy.ShouldBe("lead");
|
||||||
|
finished.ShouldBe(["Completed"]);
|
||||||
|
fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
|
||||||
|
fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_fresh_run_is_visible_too()
|
||||||
|
{
|
||||||
|
// Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
|
||||||
|
var fixture = new EngineFixture();
|
||||||
|
var agent = fixture.AddAgent("worker");
|
||||||
|
fixture.Client.RespondsWithText("Fertig.");
|
||||||
|
|
||||||
|
var started = new List<ActiveRun>();
|
||||||
|
fixture.Engine.Activity.RunStarted += started.Add;
|
||||||
|
|
||||||
|
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
|
||||||
|
|
||||||
|
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user