using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Activity;
/// A run that is in progress right now.
public sealed record ActiveRun(
string RunId,
string AgentId,
string Source,
string? TaskId,
string? CalledBy,
DateTime StartedAtUtc);
///
/// One agent reaching another — for now a delegated run ( =
/// delegation). Meetings and escalations will add their own kinds.
///
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
///
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
///
/// Why a component of its own. The engine already tracked running chats, but
/// only those that went through ChatAsync; a fresh run (@new) was invisible to
/// IsRunning. 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.
///
/// Memory only, and bounded. This is the view of "now". What happened
/// yesterday is in the audit log; the time line (#711, later) replays it from there.
///
public sealed class AgentActivity
{
/// How many recent links are kept for a page that connects late.
public const int RecentLinkCapacity = 500;
private readonly Lock _lock = new();
private readonly Dictionary _active = new(StringComparer.Ordinal);
private readonly Queue _recentLinks = new();
private readonly TimeProvider _clock;
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
public event Action? RunStarted;
/// A run ended — with the AgentRunStatus name, or Aborted.
public event Action? RunFinished;
public event Action? LinkObserved;
///
/// Announces a run. Dispose the handle when it ends; call
/// first to report how. A delegated run ( deeper than its root)
/// also records the link from the caller.
///
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);
}
/// #697: A run in progress, by its id.
public ActiveRun? Find(string runId)
{
lock (_lock)
return _active.GetValueOrDefault(runId);
}
/// The runs in progress right now.
public IReadOnlyList ActiveRuns()
{
lock (_lock)
return [.. _active.Values];
}
/// Links observed within , oldest first.
public IReadOnlyList 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));
}
/// A subscriber that throws must not break the run it is watching.
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;
}
/// How the run ended. Without it, disposal reports Aborted.
public void Complete(string status) => _status = status;
public void Dispose() => _owner.End(_run, _status);
}
}