Files
roboco/roboco/events/stream_bus.py
T
b3057628b0 [499f9eb1] Token Usage & Cost Analytics — Full-Stack Instrumentation, Persistence, and Visualization (#90)
* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88)

- Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice
- Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage
- Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks
- Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost
- Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel
- Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination)
- Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table)
- Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through
- Install recharts 3.8.1
- Export all new symbols through their barrel index.ts files

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89)

* [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables

Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups
tables with correct BIGINT columns, indexes, and unique constraint.
Chain: 025_agentrole_prompter → 026_token_usage_tables.

* [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation

Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable
to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns
match the migration schema with BIGINT token counts and proper indexes.

* [10372f0f] feat(billing): add pricing module with calculate_cost() function

Create roboco/billing/__init__.py and roboco/billing/pricing.py with
calculate_cost() supporting Claude opus/sonnet/haiku models with
input/output/cache pricing. Unknown models return 0.0 without raising.

* [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK

Extend _SessionState with token counters. Add TokenReportRequest and
TokenUsageStatus models. POST /usage/report additively accumulates token
counts; GET /usage/status returns current session totals for sweeper polling.

* [10372f0f] feat(orchestrator): add token usage instrumentation hooks

- _launch_spawn() calls _record_spawn_session() after successful container spawn
- stop_agent() calls _finalize_spawn_session() before container removal
- _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick
- New methods: _record_spawn_session, _finalize_spawn_session,
  _sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section

* [10372f0f] feat(api): add token usage analytics API with 7 endpoints

Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py.
Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team,
/by-model, /projection, /cache-efficiency. Register in app.py.

* [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard

Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas.
Add usage_summary: UsageSummary | None to CEOOverview. Update
get_ceo_overview() to populate usage_summary from daily_usage_rollups.

* [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py

- Remove unreachable `except Exception as e` block in orchestrator.py
  _sweep_daily_rollup() (lines 3376-3381) which referenced undefined
  `agent_id` and was copy-pasted from _sweep_token_snapshots by mistake
- Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/
  haiku tiers with all 4 token types, unknown model → 0.0, empty string
  → 0.0, and substring-match priority (longer fragment wins)
- Add tests/unit/services/test_usage.py: 25 tests covering get_summary
  trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model
  pct_of_total summing to 100%, get_projection formula (avg_daily×30),
  and get_cache_efficiency hit-rate and cost_saved arithmetic
- pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target)

* [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation

get_time_series() previously computed total_tokens as tokens_input +
tokens_output only. get_summary() includes all 4 token types (input +
output + cache_read + cache_write). AC9 requires both endpoints to agree
on their totals for the same period.

Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in
get_time_series() and include them in the total_tokens calculation.

Also adds 4 new unit tests in TestGetTimeSeries covering:
- total_tokens includes cache_read and cache_write (the AC9 guard)
- zero cache tokens still produces correct total
- empty result returns empty list
- required fields are present in each point

* [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10)

- Remove import math (F401 — never used)
- Remove text from sqlalchemy import (F401 — never used)
- Remove unused local calculate_cost import inside get_cache_efficiency (F401)
- Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent,
  get_by_team, and get_by_model; update grand_total and per-item total to
  include all 4 token types so totals match get_summary() (AC10 fix)
- Update test mock rows to include explicit tokens_cache_read=0 and
  tokens_cache_write=0 so they work with the fixed code
- Add new test cases: test_cache_tokens_included_in_total_tokens and
  test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94)

Update all usage-related frontend code to match the actual FastAPI backend
response shapes and endpoint paths:

- panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct
  endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model,
  /usage/by-team, /usage/time-series, /usage/projection,
  /usage/cache-efficiency); send period query param (24h/7d/30d not hours);
  mock generators produce data matching real backend shapes exactly;
  getUsageSessions returns [] in prod (no /usage/sessions endpoint exists)

- panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary
  (tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow
  to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow,
  UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use
  bucket field; update UsageSession to use agent_slug

- panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types;
  add useTeamUsage, useUsageProjection, useCacheEfficiency hooks

- panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field
  (not timestamp) for axis labels
- panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and
  total_tokens (not agent_name/tokens_today)
- panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept
  TeamUsageRow[] from API directly
- panel/src/components/metrics/model-usage-donut.tsx: use total_tokens,
  cost_usd, pct_of_total (not tokens/cost/percentage)
- panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys
  updated
- panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary
  with tokens_input/tokens_output/total_cost_usd/trend_pct
- panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add
  TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types
- panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug
- panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95)

- Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace
  hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT
- Add UUID to TYPE_CHECKING imports to satisfy ruff F821
- Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring
  self._lock so the SDK HTTP round-trip does not hold the lock
- Add usage_session_id: UUID | None field to AgentInstance dataclass
- Change _record_spawn_session to return UUID | None; wire return value
  back to instance.usage_session_id in _launch_spawn
- Update _finalize_spawn_session to use WHERE id=usage_session_id for
  direct session row lookup when usage_session_id is not None
- Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup
  aggregate query to avoid re-aggregating all-time history each sweep

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98)

- pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75)
  and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama
  zero-cost early-return; add structlog warning for unmatched model names
- app.py: move usage_router import before routes.v1 block (ruff isort fix)
- orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import;
  add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports
- orchestrator.py _sweep_token_snapshots: prefer direct lookup by
  instance.usage_session_id; fall back to agent_slug heuristic only when None
- tests: add test_sweep_daily_rollup_inserts_new_row and
  test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py
- usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* Mypy compliance

* fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation

The master merge brought in 026_completed_dependency_ids alongside the rework's
026_token_usage_tables — both off 025, forking the alembic head and breaking
the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids
(linear chain, single head).

Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old
NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root
to PENDING (Main PM's claim source). Update the assertion.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-10 14:38:44 +02:00

455 lines
15 KiB
Python

"""
Stream Event Bus
Redis Streams-based event system with durable message delivery.
Replaces the pub/sub-based EventBus with persistence and consumer groups.
"""
import asyncio
import contextlib
import os
import socket
from collections.abc import Callable, Coroutine
from typing import Any
import redis.asyncio as redis
import structlog
from redis.exceptions import ResponseError
from redis.exceptions import TimeoutError as RedisTimeoutError
from roboco.config import settings
from roboco.models.events import Event, EventType
logger = structlog.get_logger()
# Type for event handlers
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
class StreamEventBus:
"""
Event bus using Redis Streams for durable message delivery.
Features:
- Message persistence (survives Redis restart with AOF)
- Consumer groups for at-least-once delivery
- Message acknowledgment after successful processing
- Automatic stream trimming (configurable retention)
"""
STREAM_PREFIX = "roboco:stream:"
DEFAULT_GROUP = "roboco-handlers"
MAX_STREAM_LENGTH = 10000 # Trim streams to this length
def __init__(
self,
redis_url: str | None = None,
consumer_name: str | None = None,
group_name: str | None = None,
):
self.redis_url = redis_url or settings.redis_url
# Default consumer name is stable across restarts of the same process
# (host + pid), so pending messages don't get orphaned to a new
# id(self)-based name every time the orchestrator restarts. Redis
# consumer groups still auto-reassign via xclaim after idle_time.
self.consumer_name = consumer_name or (
f"consumer-{socket.gethostname()}-{os.getpid()}"
)
self.group_name = group_name or self.DEFAULT_GROUP
self._redis: redis.Redis | None = None
self._handlers: dict[EventType, list[EventHandler]] = {}
self._running = False
self._listen_task: asyncio.Task | None = None
async def connect(self) -> None:
"""Connect to Redis."""
self._redis = redis.from_url(self.redis_url)
logger.info("StreamEventBus connected to Redis")
def is_connected(self) -> bool:
"""Check if the event bus is connected to Redis."""
return self._redis is not None
async def disconnect(self) -> None:
"""Disconnect from Redis."""
self._running = False
if self._listen_task:
self._listen_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._listen_task
if self._redis:
await self._redis.close()
logger.info("StreamEventBus disconnected")
def _get_stream_name(self, event_type: EventType) -> str:
"""Get stream name for event type (grouped by prefix)."""
# Group by event category: task.*, agent.*, notification.*, etc.
category = event_type.value.split(".")[0]
return f"{self.STREAM_PREFIX}{category}"
def _get_all_stream_names(self) -> list[str]:
"""Get all stream names for registered handlers."""
categories = set()
for event_type in self._handlers:
category = event_type.value.split(".")[0]
categories.add(category)
return [f"{self.STREAM_PREFIX}{cat}" for cat in categories]
async def _ensure_consumer_group(self, stream: str) -> None:
"""Ensure consumer group exists for stream."""
if not self._redis:
return
try:
await self._redis.xgroup_create(
stream,
self.group_name,
id="0",
mkstream=True,
)
logger.debug("Created consumer group", stream=stream, group=self.group_name)
except ResponseError as e:
if "BUSYGROUP" not in str(e):
raise
# Group already exists, that's fine
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
"""Subscribe a handler to an event type."""
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
logger.debug("Handler subscribed", event_type=event_type.value)
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
"""Unsubscribe a handler from an event type."""
if event_type in self._handlers:
self._handlers[event_type] = [
h for h in self._handlers[event_type] if h != handler
]
async def publish(self, event: Event) -> str:
"""
Publish an event to the stream.
Returns the message ID assigned by Redis.
"""
if not self._redis:
raise RuntimeError("StreamEventBus not connected")
stream = self._get_stream_name(event.type)
# Add to stream with automatic ID (*) and trim to max length
raw_message_id = await self._redis.xadd(
stream,
{
"type": event.type.value,
"data": event.to_json(),
},
maxlen=self.MAX_STREAM_LENGTH,
approximate=True,
)
# Convert bytes to str if needed
message_id = (
raw_message_id.decode()
if isinstance(raw_message_id, bytes)
else str(raw_message_id)
)
logger.info(
"Event published to stream",
event_type=event.type.value,
event_id=str(event.id),
stream=stream,
message_id=message_id,
source=event.source_agent,
)
return message_id
async def publish_task_event(
self,
event_type: EventType,
task_id: str,
agent_id: str | None = None,
**extra_data: Any,
) -> str:
"""Convenience method to publish task-related events."""
event = Event(
type=event_type,
data={"task_id": task_id, **extra_data},
source_agent=agent_id,
)
return await self.publish(event)
async def start_listening(self) -> None:
"""Start listening for events."""
if not self._redis:
raise RuntimeError("StreamEventBus not connected")
streams = self._get_all_stream_names()
if not streams:
logger.warning("No event handlers registered, nothing to subscribe to")
return
# Ensure consumer groups exist for all streams
for stream in streams:
await self._ensure_consumer_group(stream)
self._running = True
self._listen_task = asyncio.create_task(self._listen_loop())
logger.info("StreamEventBus listening", streams=streams)
async def _listen_loop(self) -> None:
"""Main event listening loop using XREADGROUP."""
if not self._redis:
return
streams = self._get_all_stream_names()
# Build stream dict: {stream_name: ">"} (> = only new messages)
stream_dict = dict.fromkeys(streams, ">")
while self._running:
try:
await self._listen_tick(stream_dict)
except asyncio.CancelledError:
break
except ResponseError as e:
if await self._handle_response_error(e, streams):
continue
await asyncio.sleep(1)
except (RedisTimeoutError, TimeoutError):
# An idle XREADGROUP(block=...) hits the client read-timeout when
# no new message arrives within the block window. This is the
# normal idle path, not an error — re-block on the next iteration
# without logging or back-off.
continue
except Exception as e:
logger.error("Error in stream event loop", error=str(e))
await asyncio.sleep(1)
async def _listen_tick(self, stream_dict: dict[str, str]) -> None:
"""Block for one XREADGROUP cycle and dispatch any messages."""
assert self._redis is not None
results = await self._redis.xreadgroup(
self.group_name,
self.consumer_name,
stream_dict,
count=10,
block=5000,
)
if not results:
return
for stream_name, messages in results:
for message_id, data in messages:
await self._handle_message(stream_name, message_id, data)
async def _handle_response_error(
self, exc: ResponseError, streams: list[str]
) -> bool:
"""Recover from NOGROUP by rebootstrapping; return True iff recovered."""
if "NOGROUP" in str(exc):
logger.warning(
"Stream consumer group missing; recreating",
group=self.group_name,
)
for stream in streams:
await self._ensure_consumer_group(stream)
return True
logger.error("Error in stream event loop", error=str(exc))
return False
@staticmethod
def _decode_event_data(data: dict) -> str | None:
"""Pull the event payload out of a stream record."""
event_data = data.get(b"data") or data.get("data")
if isinstance(event_data, bytes):
event_data = event_data.decode()
if not event_data or not isinstance(event_data, str):
return None
return event_data
@staticmethod
def _check_handler_results(event: Event, handlers: list, results: list) -> bool:
"""Log handler errors; return True only when every handler succeeded."""
all_succeeded = True
for i, result in enumerate(results):
if isinstance(result, Exception):
all_succeeded = False
logger.error(
"Event handler error",
event_type=event.type.value,
handler=handlers[i].__name__,
error=str(result),
)
return all_succeeded
async def _dispatch_event(self, event: Event) -> bool:
"""Run all handlers for an event; return True if all succeeded."""
handlers = self._handlers.get(event.type, [])
if not handlers:
return True
logger.debug(
"Handling event from stream",
event_type=event.type.value,
handler_count=len(handlers),
)
tasks = [handler(event) for handler in handlers]
results = await asyncio.gather(*tasks, return_exceptions=True)
return self._check_handler_results(event, handlers, results)
async def _handle_message(
self,
stream: str,
message_id: str,
data: dict,
) -> None:
"""Handle an incoming message and ACK on success."""
if not self._redis:
return
try:
event_data = self._decode_event_data(data)
if event_data is None:
logger.error("Invalid event data", message_id=message_id)
await self._redis.xack(stream, self.group_name, message_id)
return
event = Event.from_json(event_data)
all_succeeded = await self._dispatch_event(event)
# ACK the message if all handlers succeeded
# If any failed, message stays pending and can be reclaimed later
if all_succeeded:
await self._redis.xack(stream, self.group_name, message_id)
logger.debug("Message acknowledged", message_id=message_id)
else:
logger.warning(
"Message not acknowledged due to handler errors",
message_id=message_id,
)
except Exception as e:
logger.error(
"Failed to handle stream message",
error=str(e),
message_id=message_id,
)
async def _claim_and_handle(
self, stream: str, msg_id: str, idle_time_ms: int
) -> int:
"""Claim a single idle message and process it; return count recovered."""
if self._redis is None:
raise RuntimeError("Invariant: self._redis must be set — guarded by caller")
claimed = await self._redis.xclaim(
stream,
self.group_name,
self.consumer_name,
min_idle_time=idle_time_ms,
message_ids=[msg_id],
)
if not claimed:
return 0
for claim_id, data in claimed:
await self._handle_message(stream, claim_id, data)
return 1
async def _recover_stream(self, stream: str, idle_time_ms: int) -> int:
"""Recover idle pending messages from a single stream."""
if self._redis is None:
raise RuntimeError("Invariant: self._redis must be set — guarded by caller")
pending = await self._redis.xpending(stream, self.group_name)
if not pending or pending["pending"] == 0:
return 0
pending_details = await self._redis.xpending_range(
stream,
self.group_name,
min="-",
max="+",
count=100,
)
recovered = 0
for msg in pending_details:
if int(msg["time_since_delivered"]) >= idle_time_ms:
recovered += await self._claim_and_handle(
stream, str(msg["message_id"]), idle_time_ms
)
return recovered
async def recover_pending(self, idle_time_ms: int = 60000) -> int:
"""
Recover pending messages that weren't acknowledged.
Useful for startup to process messages from crashed consumers.
Args:
idle_time_ms: Only recover messages idle for this long (default 1 minute)
Returns:
Number of messages recovered
"""
if not self._redis:
return 0
recovered = 0
for stream in self._get_all_stream_names():
try:
recovered += await self._recover_stream(stream, idle_time_ms)
except Exception as e:
logger.error(
"Error recovering pending messages",
stream=stream,
error=str(e),
)
if recovered:
logger.info("Recovered pending messages", count=recovered)
return recovered
# =============================================================================
# SINGLETON ACCESS
# =============================================================================
class _StreamEventBusHolder:
"""Holder for singleton StreamEventBus instance."""
instance: StreamEventBus | None = None
def get_stream_event_bus() -> StreamEventBus:
"""Get or create the global stream event bus instance."""
if _StreamEventBusHolder.instance is None:
_StreamEventBusHolder.instance = StreamEventBus()
return _StreamEventBusHolder.instance
async def init_stream_event_bus(
consumer_name: str | None = None,
recover_pending: bool = True,
) -> StreamEventBus:
"""
Initialize and start the stream event bus.
Args:
consumer_name: Unique name for this consumer instance
recover_pending: Whether to recover unacknowledged messages on startup
"""
bus = get_stream_event_bus()
if consumer_name:
bus.consumer_name = consumer_name
await bus.connect()
if recover_pending:
await bus.recover_pending()
return bus