Lagebild, Stufe 0: Teamuebersicht in der WebUI (#711)

Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es,
auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten
zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen.

Kern:
- AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe,
  Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start,
  Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync
  "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest,
  wer wen gerufen hatte.
- Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem
  Agententor.

Anwendung:
- TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der
  Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des
  letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem
  Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge:
  haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange
  nichts Neues laeuft.

Web:
- GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis
  "run" traegt jetzt die Agenten-Id.
- Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus
  der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen
  nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben,
  letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und
  Laeufe. Sprachpakete en/de.
- Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu
  gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden).

Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch
Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste,
Live-Kante und Verblassen.

Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:34:46 +02:00
co-authored by Claude Opus 5.5
parent d587dc3f20
commit f627918729
17 changed files with 1153 additions and 1 deletions
@@ -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>
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>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// 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 chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(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
{
// #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 result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);