4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
224 lines
7.9 KiB
Python
224 lines
7.9 KiB
Python
"""Background sampler that drives the agents dashboard.
|
|
|
|
Each tick, for every registered or auto-discovered agent:
|
|
|
|
1. ``probe()`` (psutil) → :class:`ProcessSnapshot` stored in :data:`_latest`.
|
|
2. Provider lookup → :class:`TokenSource` read → :class:`TokenMeter`
|
|
parse → :data:`TOKEN_RATE_TRACKER` record.
|
|
3. GC pass: any PID not in this tick's live set is forgotten from
|
|
the tracker AND from every source's per-PID cache.
|
|
|
|
Both steps run in the asyncio executor (file I/O is blocking).
|
|
Per-agent failures are swallowed at debug level so a single bad
|
|
process never tears down the loop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from tools.system.fleet_monitoring.config import AgentsConfig, load_agents_config
|
|
from tools.system.fleet_monitoring.discovery import registered_and_discovered_agents
|
|
from tools.system.fleet_monitoring.meters.registry import TOKEN_METER_REGISTRY, get_token_meter
|
|
from tools.system.fleet_monitoring.pricing import (
|
|
PriceOverride,
|
|
normalize_model_name,
|
|
usd_per_hour_for_usage,
|
|
)
|
|
from tools.system.fleet_monitoring.probe import ProcessSnapshot, env_value_for_pid, probe
|
|
from tools.system.fleet_monitoring.providers import provider_for
|
|
from tools.system.fleet_monitoring.registry import AgentRecord, AgentRegistry
|
|
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
|
|
from tools.system.fleet_monitoring.token_sources.registry import (
|
|
TOKEN_SOURCE_REGISTRY,
|
|
get_token_source,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_latest: dict[int, ProcessSnapshot] = {}
|
|
|
|
|
|
class _TickCache:
|
|
"""Per-tick caches refreshed by ``_sampler_loop`` so render-time
|
|
lookups don't re-parse disk/discovery state for each row.
|
|
|
|
Wrapped in a class (not module-level globals) so the writer
|
|
inside ``_sampler_loop`` can assign attributes without a
|
|
``global`` declaration — CodeQL flagged the global form as
|
|
"unused" because the reads happen in other functions.
|
|
"""
|
|
|
|
registry_snapshot: dict[int, AgentRecord] = {} # noqa: RUF012
|
|
agents_config: AgentsConfig | None = None
|
|
|
|
|
|
_MODEL_ENV_KEYS: dict[str, str] = {
|
|
"claude-code": "CLAUDE_CODE_MODEL",
|
|
"codex": "CODEX_MODEL",
|
|
}
|
|
|
|
|
|
def get_snapshot(pid: int) -> ProcessSnapshot | None:
|
|
return _latest.get(pid)
|
|
|
|
|
|
def get_tokens_per_min(pid: int) -> float | None:
|
|
return TOKEN_RATE_TRACKER.tokens_per_min(pid)
|
|
|
|
|
|
def get_usd_per_hour(pid: int) -> float | None:
|
|
return _compute_usd_per_hour(pid)
|
|
|
|
|
|
async def _sampler_loop(interval: float) -> None:
|
|
while True:
|
|
registry = AgentRegistry()
|
|
agents = _records_for_tick(registry)
|
|
# Build the new snapshot locally and swap by reference so the
|
|
# view thread never observes a transient empty cache between
|
|
# ``clear()`` and the first ``__setitem__``.
|
|
_TickCache.registry_snapshot = {record.pid: record for record in agents}
|
|
try:
|
|
_TickCache.agents_config = load_agents_config()
|
|
except Exception:
|
|
logger.debug("failed to load agents.yaml during tick refresh", exc_info=True)
|
|
_TickCache.agents_config = None
|
|
|
|
live_pids: set[int] = set()
|
|
for agent in agents:
|
|
live_pids.add(agent.pid)
|
|
try:
|
|
snapshot = await asyncio.get_running_loop().run_in_executor(None, probe, agent.pid)
|
|
if snapshot is not None:
|
|
_latest[agent.pid] = snapshot
|
|
else:
|
|
_latest.pop(agent.pid, None)
|
|
except Exception:
|
|
logger.debug("probe failed for pid %d", agent.pid, exc_info=True)
|
|
try:
|
|
await asyncio.get_running_loop().run_in_executor(None, _sample_tokens, agent)
|
|
except Exception:
|
|
logger.debug("token sample failed for pid %d", agent.pid, exc_info=True)
|
|
_gc_disappeared_agents(live_pids)
|
|
await asyncio.sleep(interval)
|
|
|
|
|
|
def _records_for_tick(registry: AgentRegistry) -> list[AgentRecord]:
|
|
try:
|
|
return registered_and_discovered_agents(registry)
|
|
except Exception:
|
|
logger.debug("agent discovery failed during sampler tick", exc_info=True)
|
|
return registry.list()
|
|
|
|
|
|
def _sample_tokens(agent: AgentRecord) -> None:
|
|
provider = provider_for(agent)
|
|
if provider is None:
|
|
return
|
|
source = get_token_source(provider)
|
|
chunk = source.read_new_chunk(agent.pid)
|
|
if chunk is None:
|
|
return
|
|
meter = get_token_meter(provider)
|
|
sample = meter.sample_chunk(chunk, pid=agent.pid)
|
|
# Always record, even with tokens=0, so a known-idle PID renders
|
|
# as ``0`` rather than the never-seen ``-``.
|
|
TOKEN_RATE_TRACKER.record(agent.pid, usage=sample.usage, model=sample.model)
|
|
|
|
|
|
def _gc_disappeared_agents(live_pids: set[int]) -> None:
|
|
"""Drop tracker + source state for PIDs no longer in the registry.
|
|
|
|
Iterates both the tracker AND each source's own PID cache: a
|
|
source can resolve a PID without the tracker ever recording an
|
|
entry (e.g. a long-idle agent that the meter scores at 0 every
|
|
tick and the wiring layer skips before tracker.record), and
|
|
that state must still be GC'd.
|
|
"""
|
|
known = set(TOKEN_RATE_TRACKER.known_pids())
|
|
for source in TOKEN_SOURCE_REGISTRY.values():
|
|
known.update(source.known_pids())
|
|
for meter in TOKEN_METER_REGISTRY.values():
|
|
known.update(meter.known_pids())
|
|
for pid in known - live_pids:
|
|
TOKEN_RATE_TRACKER.forget(pid)
|
|
for source in TOKEN_SOURCE_REGISTRY.values():
|
|
source.forget(pid)
|
|
for meter in TOKEN_METER_REGISTRY.values():
|
|
meter.forget(pid)
|
|
|
|
|
|
def _compute_usd_per_hour(pid: int) -> float | None:
|
|
usage_per_min = TOKEN_RATE_TRACKER.usage_per_min(pid)
|
|
if usage_per_min is None:
|
|
return None
|
|
model = _resolved_model_for_pid(pid)
|
|
override = _price_override_for_pid(pid)
|
|
return usd_per_hour_for_usage(usage_per_min, model, override)
|
|
|
|
|
|
def _price_override_for_pid(pid: int) -> PriceOverride | None:
|
|
config = _TickCache.agents_config
|
|
if config is None:
|
|
return None
|
|
record = _registry_record_for(pid)
|
|
if record is None:
|
|
return None
|
|
budget = config.agents.get(record.name)
|
|
if budget is None:
|
|
return None
|
|
if budget.input_usd_per_million_tokens is None and budget.output_usd_per_million_tokens is None:
|
|
return None
|
|
return PriceOverride(
|
|
input_usd_per_million=budget.input_usd_per_million_tokens,
|
|
output_usd_per_million=budget.output_usd_per_million_tokens,
|
|
)
|
|
|
|
|
|
def _resolved_model_for_pid(pid: int) -> str | None:
|
|
"""Three-source model resolution: NDJSON > yaml override > env var.
|
|
|
|
NDJSON is most accurate (reflects the live session). The yaml
|
|
override exists for the macOS hardened-runtime case where
|
|
``psutil.environ()`` is denied even for same-user processes.
|
|
"""
|
|
tracked = TOKEN_RATE_TRACKER.latest_model(pid)
|
|
if tracked is not None:
|
|
return normalize_model_name(tracked)
|
|
|
|
record = _registry_record_for(pid)
|
|
if record is None:
|
|
return None
|
|
|
|
config = _TickCache.agents_config
|
|
if config is not None:
|
|
budget = config.agents.get(record.name)
|
|
if budget is not None and budget.model is not None:
|
|
return normalize_model_name(budget.model)
|
|
|
|
provider = provider_for(record)
|
|
if provider is None:
|
|
return None
|
|
env_key = _MODEL_ENV_KEYS.get(provider)
|
|
if env_key is None:
|
|
return None
|
|
return normalize_model_name(env_value_for_pid(pid, env_key))
|
|
|
|
|
|
def _registry_record_for(pid: int) -> AgentRecord | None:
|
|
# The sampler-tick cache is the fast path. Non-interactive
|
|
# callers (e.g. ``opensre fleet list``) never enter the loop,
|
|
# so the cache stays empty and we fall back to disk.
|
|
record = _TickCache.registry_snapshot.get(pid)
|
|
if record is not None:
|
|
return record
|
|
if _TickCache.registry_snapshot:
|
|
return None
|
|
return AgentRegistry().get(pid)
|
|
|
|
|
|
def start_sampler(interval: float = 5.0) -> asyncio.Task[None]:
|
|
return asyncio.create_task(_sampler_loop(interval))
|