mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(rate-limit): real provider liveness probe instead of time-based stub
The rate-limit recovery sweeper cleared a provider and resumed parked agents
purely on elapsed time — _do_probe was a stub that always returned True once
the retry_after window passed, so it never confirmed the provider had actually
stopped rate-limiting us. Under a sustained limit that resumes agents straight
into another 429, re-parking them: avoidable churn.
Make the probe real. _do_probe now issues a free, unmetered liveness call —
Anthropic GET /v1/models or Ollama GET /api/tags — and treats any non-429
response as the limit having lifted. A 429 keeps the provider parked; a
network error keeps it parked too (retry next sweep). When the provider can't
be probed (no API key, or an unrecognized provider), it falls back to the
prior time-expiry optimism rather than stranding agents. _probe_target keeps
URL/header resolution separate and testable, and _do_probe stays a
monkeypatchable boundary so the existing sweep tests are unaffected.
Also drop two acceptance-criteria-number labels from comments in this file.
* chore(rate-limit): clear merged gate debt in rate-limit tests + deps lint
The rate-limit PR landed with ruff violations the full gate flags but the
authors' runs missed: test_rate_limit_sweep.py was unformatted, and
test_rate_limit_tracker.py had unsorted/unused imports and magic-value
comparisons. Format the sweep test, drop the dead imports, and bind the
magic comparison values to locals. Also strip acceptance-criteria-number
labels from comments/docstrings across the three rate-limit test files
(leaving genuine acceptance_criteria=[...] test data untouched), and add
api/deps.py to the PLC0415 per-file-ignore — it is the DI wiring hub and
defers a couple of service imports to call time to avoid import cycles,
the same rationale already applied to api/routes, runtime, and services.
* fix(rate-limit): resolve redis type errors in RateLimitStateTracker
A cold mypy run (the gate's true state — prior passes were warm-cache only)
flagged four redis-typing errors in rate_limit_tracker.py that the merge
missed: three unused type:ignore[type-arg] on redis.Redis, and an
aclose() the bundled redis type stub doesn't expose.
Drop the now-unused ignores, and close the scan client via
'async with redis.from_url(...) as r:' instead of a finally-block
aclose(). The context manager closes the client on exit using the modern
redis.asyncio API — no deprecated close(), no stub-missing aclose(), no
suppression. Extend the test's redis mock to model the async
context-manager protocol so it returns itself on enter.
* test(prompter): pass route='main_pm' in the product main-PM routing test
Pre-existing master failure, unrelated to the rate-limit work. The test is
named ...product_routes_to_main_pm and asserts team=MAIN_PM, but called
confirm_live_draft without a route, so it got the 'board' default — which
assigns the Product Owner and yields team=BOARD by design (the board-review
path keeps the root at team=board until the CEO approves). The Main-PM path
is selected with route='main_pm', exactly as the sibling
...main_pm_route_assigns_main_pm test does. Add the missing kwarg so the test
verifies the path it names; behaviour under test is unchanged.
* Updated uv.lock
* refactor(complexity): bring all rank-C blocks under the xenon B ceiling
The full quality gate's xenon step (--max-absolute B --max-modules A
--max-average A) failed on eight rank-C blocks plus the extraction module
average — debt the rate-limit and token-analytics merges deferred. Reduce
each by extracting cohesive helpers, behaviour unchanged:
- orchestrator._probe_one_provider: split into _too_early_to_probe,
_on_probe_success, _on_probe_failure, _parked_agents_for.
- rate_limit_tracker.list_rate_limited_providers: extract _read_rate_limited_entry
and a _decode helper.
- trigger_filter.decide_spawn: extract _stale_trigger_decision (drops the
PLR0911 suppression too).
- ollama_embedder (embed_query, _embed_batch_sync, aembed_query,
_embed_batch_async): share _rl_backoff / _map_embed_error / _log_429 /
_sleep_connect_retry / _asleep_connect_retry; remove a dead post-loop guard
in aembed_query.
- mentor._synthesize_answer: extract _select_system_prompt and
_answer_from_response.
- indexes/base.ask: extract the 429-retried LLM call into _ask_llm.
- extraction.__init__: extract _compile_patterns so the module average
lands at rank A.
xenon now exits 0; rate-limit, optimal_brain, extraction, and events suites
all green.
* chore(deps): drop obsolete types-redis stub; honor redis 8.0 inline types
types-redis 4.6 (typed for redis 4.x) shadowed redis 8.0's own inline types,
which both masked real annotation mismatches in stream_bus.py and forced
awkward workarounds elsewhere. The stale stub is why the mypy gate only ever
passed warm-cached: a cold run under the wrong stub disagreed with the code.
Remove types-redis (and its orphaned transitive stubs) so mypy uses redis's
shipped types. That surfaces that xreadgroup/xclaim return bytes-keyed records
while _handle_message is annotated str — the code already decodes bytes
defensively, so this is an annotation gap, not a runtime bug. Make the types
honest: cast each result to its concrete shape and decode the stream name and
message id to str at the dispatch boundary via a _to_str helper.
mypy roboco/ is now clean cold (247 files) against redis's real types; events
suite green.
* Updated uv.lock
* fix(workspace): install the dev extra so agents can run make quality
Agent workspaces were set up with plain `uv sync`, which installs only the
project's default dependency group (pytest) — not the `dev` *extra* where the
gate tools live (ruff, mypy, xenon, radon, vulture, bandit, deptry). So an
agent's .venv had pytest but no linters, and `make quality` died immediately
on `ruff: command not found`. Agents literally could not lint, type-check, or
complexity-check their own work, which is how format/mypy/xenon debt merged
unseen. Sync the `dev` extra (`uv sync --extra dev`) so the workspace gets the
full toolchain the setup's own docstring already promised.
* fix(panel): rate-limit endpoint shape + websocket path
Two panel-facing breakages from the rate-limit rework:
- GET /api/system/rate-limits returned a raw list, but the panel store reads
response.entries — so `r.entries is not iterable` crashed the banner sync on
page load. Return the panel's contract: a { entries: [...] } envelope whose
items are camelCase {provider, affectedAgents, hitAt, resumeAt,
retryAfterSeconds}, derived from the raw Redis state (resumeAt = hitAt +
retryAfter).
- The rate-limit websocket hook passed "/ws/system" while getWebSocketUrl()
already supplies the "/ws" base, producing the doubled "/ws/ws/system" URL.
Pass "/system" to match the agents/channels/notifications hooks.
Note: the backend /ws/system endpoint itself does not yet exist (the rework
shipped the panel hook only); the REST fix keeps the banner correct on load
and reconnect until that endpoint is built.
* test(workspace): assert uv sync installs the dev extra
Follow the workspace setup change: the dependency-install command is now
`uv sync --extra dev` so the agent workspace gets the lint/type/complexity
toolchain. Update the three assertions that pinned the old `uv sync`.
* feat(ws): add /ws/system stream and bridge rate-limit events to the panel
The rate-limit rework shipped the panel's websocket hook but no backend: there
was no /ws/system endpoint and nothing forwarded RATE_LIMIT_HIT/LIFTED to a
socket, so the banner got no live updates.
Build the missing half:
- ConnectionManager grows a system-wide connection set with connect_system /
broadcast_system, and disconnect() now clears it.
- A /ws/system websocket endpoint (operator stream, no per-agent keying) with
the same connected + ping/pong lifecycle as the other streams.
- websocket_bridge subscribes RATE_LIMIT_HIT/LIFTED and forwards each to
broadcast_system tagged with the type the panel switches on. Both events
ride the same StreamEventBus singleton, and the subscriptions register
before start_listening(), so the consumer reads their streams.
Pairs with the panel hook now passing '/system' (getWebSocketUrl supplies the
'/ws' base). Covered by handler, manager, and endpoint-lifecycle tests.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
468 lines
16 KiB
Python
468 lines
16 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, cast
|
|
|
|
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)
|
|
|
|
@staticmethod
|
|
def _to_str(value: object) -> str:
|
|
"""Decode a Redis stream/key value (bytes or str) to str."""
|
|
return value.decode() if isinstance(value, bytes) else str(value)
|
|
|
|
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
|
|
# redis returns bytes-keyed records (no decode_responses); the concrete
|
|
# shape is list[(stream, [(id, fields)])]. _handle_message takes str, so
|
|
# decode the stream name and message id at this boundary.
|
|
raw = await self._redis.xreadgroup(
|
|
self.group_name,
|
|
self.consumer_name,
|
|
cast("dict[Any, Any]", stream_dict),
|
|
count=10,
|
|
block=5000,
|
|
)
|
|
if not raw:
|
|
return
|
|
results = cast(
|
|
"list[tuple[bytes, list[tuple[bytes, dict[bytes, bytes]]]]]", raw
|
|
)
|
|
for stream_name, messages in results:
|
|
stream_str = self._to_str(stream_name)
|
|
for message_id, data in messages:
|
|
await self._handle_message(stream_str, self._to_str(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")
|
|
raw = await self._redis.xclaim(
|
|
stream,
|
|
self.group_name,
|
|
self.consumer_name,
|
|
min_idle_time=idle_time_ms,
|
|
message_ids=[msg_id],
|
|
)
|
|
if not raw:
|
|
return 0
|
|
claimed = cast("list[tuple[bytes, dict[bytes, bytes]]]", raw)
|
|
for claim_id, data in claimed:
|
|
await self._handle_message(stream, self._to_str(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
|