Files
roboco/roboco/db/base.py
T
312ec990dd fix: prod triage 2026-07-08 — MCP auth residue, gateway envelopes, verb-loop cap, A2A interjection, manual spawn UX (#334)
* fix(auth): pass agent UUID to CLI-arg MCP servers (optimal/docs/search)

The container token is HMAC-signed over the agent UUID (#314), but the
optimal/docs/search MCP servers received the slug as their CLI arg and
sent X-Agent-ID=<slug>, so every research/RAG/docs call 401ed with
signature mismatch under enforced auth. Pass the already-computed
agent_uuid in the three args lists instead.

* fix(gateway): include remediate in gateway.rejected audit details

Conventions-gate rejections carry the offending file:line listing only
in the envelope's remediate field, which the audit row dropped -- ops
logs showed just the violation count with no way to see what blocked.

* fix(gateway): return envelope on do/commit git failure

A GitError from the commit verb propagated to the generic middleware
handler, so agents got a raw error blob with no remediate/next. Catch
it and return an error envelope; 'no changes added to commit' with an
explicit files list now names the mismatch and the omit-files fallback.

* fix(agent-sdk): absolute rejection cap breaks slow-drip verb loops

The verb circuit breaker only counted rejections inside a 60s sliding
window, so an agent retrying i_am_done every 3-4 minutes looped for 30+
minutes without tripping it. Add a session-scoped cumulative per-(verb,
task) cap at 3x the windowed limit that trips regardless of pacing.

* feat(a2a): CEO chime-in interjects into the viewed conversation

Previously reply_as_ceo re-homed the message into a canonical CEO<->target
conversation with no panel surface, so a chime-in reported success but was
invisible and only opportunistically delivered. interject_as_ceo now inserts
the message into the conversation being viewed (from_agent=ceo, directed via
an @target content prefix), bumps that conversation's counters with the
unread ping keyed to the addressed participant, and both participants see it
in transcript and read_a2a.

* feat(panel): manual spawn carries task + message, surfaces refusals

The agent detail page spawned with no request body (task/message impossible),
the spawn button could double-fire (2.5ms double-POST seen live), and refusal
reasons never reached the UI: readiness refusals were generic 500s and the
already-running no-op looked like success. Detail page now uses
SpawnAgentDialog, a synchronous ref guard blocks re-entry, AgentReadinessError
maps to 409 with its reason shown, already_running is signalled and toasted,
and a task_id builds a task-aware prompt instructing the claim (task_id alone
never did), with the CEO's message appended as a note.

* test(panel): align a2a page test with the interjection footer copy

The chime-in rebuild changed the composer footer; the page-level test
asserting the old copy was outside the rebuild's scoped vitest run.

* fix(api): commit the request DB session before the response is sent

FastAPI unwinds yield-dependencies after the response bytes go out, so
get_db's post-yield commit raced the client's next request -- a verb
could return ok while its claim/status write was still uncommitted (the
e2e ok-without-effect flake family), and a failed commit was silently
lost behind an already-sent 200. DbCommitMiddleware (innermost, pure
ASGI) commits the session stashed by get_db_committed before forwarding
http.response.start; commit failure now surfaces as a 5xx. get_db is
untouched for its direct non-request callers.

* fix(db): invalidate, not rollback, the session on request cancellation

With the commit moved into the send path, the flow-verb timeout can
cancel mid-commit; rolling back then issues another command over an
asyncpg connection stranded mid-wire-protocol, and the poisoned
connection segfaults uvloop/asyncpg when a later checkout recycles it
(3/3 identical CI faulthandler dumps). On CancelledError discard the
connection via session.invalidate() -- SQLAlchemy's documented handling
for a timeout during commit -- and keep rollback for plain exceptions.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-08 10:41:02 +02:00

321 lines
12 KiB
Python

"""
Database base configuration and session management.
"""
import asyncio
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
import structlog
from alembic import command
from alembic.config import Config
from fastapi import Depends, Request
from sqlalchemy import MetaData, text
from sqlalchemy.ext.asyncio import (
AsyncConnection,
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from roboco.config import settings
logger = structlog.get_logger()
# Naming convention for constraints (helps with migrations)
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
"""Base class for all SQLAlchemy models."""
metadata = MetaData(naming_convention=convention)
class _DbHolder:
"""Holder for database engine and session factory singletons."""
engine: AsyncEngine | None = None
session_factory: async_sessionmaker[AsyncSession] | None = None
def get_engine() -> AsyncEngine:
"""Get or create the async engine."""
if _DbHolder.engine is None:
_DbHolder.engine = create_async_engine(
settings.database_url,
echo=settings.database_echo,
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_timeout=settings.database_pool_timeout,
pool_recycle=settings.database_pool_recycle,
pool_pre_ping=True,
)
return _DbHolder.engine
def get_session_factory() -> async_sessionmaker[AsyncSession]:
"""Get or create the async session factory."""
if _DbHolder.session_factory is None:
_DbHolder.session_factory = async_sessionmaker(
bind=get_engine(),
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
return _DbHolder.session_factory
async def get_db() -> AsyncGenerator[AsyncSession]:
"""
Dependency for FastAPI routes.
Usage:
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
...
Also called directly (no ``Depends``, outside HTTP request scope) by
``websocket.py`` and a couple of read-only helpers — keep this signature
free of any required/HTTP-only parameter. For a request-scoped route that
wants its commit to land BEFORE the response reaches the client, depend
on ``get_db_committed`` instead (``roboco.api.deps.DbSession`` — the one
place every route already goes through — already does).
"""
session_factory = get_session_factory()
async with session_factory() as session:
try:
yield session
await session.commit()
except asyncio.CancelledError:
await _discard_on_cancel(session)
raise
except Exception:
await session.rollback()
raise
async def _discard_on_cancel(session: AsyncSession) -> None:
"""Discard (never reuse) a session cancelled mid-flight.
A server-side ``asyncio.timeout`` (``FlowVerbTimeoutMiddleware``) can fire
while the session is mid ``await`` on a real DBAPI round-trip — not just
while idle holding a ``FOR UPDATE`` lock, but also mid-``commit()``
(``DbCommitMiddleware`` runs its own commit in the ASGI send path, still
inside the same cancellable scope). Cancelling a greenlet-bridged asyncpg
operation mid-flight leaves the connection's wire-protocol state
undefined; SQLAlchemy's own docs (``Session.invalidate``) prescribe
exactly this: on a Timeout/cancellation, invalidate rather than rollback,
since rollback() itself would issue another command over a connection
that may already be desynced, and a desynced connection returned to the
pool is what later corrupted a *different* request's checkout (the
uvloop/asyncpg segfault class this fixes). A plain hang (asyncio.sleep,
no DBAPI call in flight when cancelled) is also safe to invalidate — just
slightly more heavy-handed than the rollback it used to get.
"""
try:
await session.invalidate()
except Exception as e:
# The connection is already being discarded; a failure tearing it
# down further (SQLAlchemy's own pool logs the underlying cause) must
# not mask the CancelledError the caller is propagating.
logger.debug("Session invalidate-on-cancel raised", error=str(e))
async def get_db_committed(
request: Request, db: Annotated[AsyncSession, Depends(get_db)]
) -> AsyncGenerator[AsyncSession]:
"""FastAPI-only wrapper around ``get_db``: stashes the live session on
``request.state.db_session`` so ``DbCommitMiddleware``
(``roboco/api/middleware.py``) can commit it BEFORE the response reaches
the client.
FastAPI resolves ``Depends(get_db)`` on the request-scoped exit stack, and
its routing sends the response to the client BEFORE that stack unwinds —
so ``get_db``'s post-yield ``commit()`` used to land after a 200 already
went out.
This is a separate function rather than a ``request`` parameter added to
``get_db`` itself: FastAPI only special-cases a dependency parameter
typed exactly ``Request`` (``lenient_issubclass`` in
``fastapi/dependencies/utils.py``) — a ``Request | None`` union is NOT
special-cased and instead gets validated as a Pydantic response field,
which crashes route registration outright (``Request`` isn't a valid
Pydantic field type). ``get_db`` is also called directly with no request
in scope, so its signature has to stay request-free; this wrapper is the
request-scoped variant, resolved once per request (FastAPI dependency
caching) so ``db`` here is the exact same session ``get_db`` yields.
"""
request.state.db_session = db
yield db
@asynccontextmanager
async def get_db_context() -> AsyncGenerator[AsyncSession]:
"""
Context manager for database sessions outside of FastAPI.
Usage:
async with get_db_context() as db:
result = await db.execute(...)
"""
session_factory = get_session_factory()
async with session_factory() as session:
try:
yield session
await session.commit()
except asyncio.CancelledError:
await _discard_on_cancel(session)
raise
except Exception:
await session.rollback()
raise
async def _db_has_tables(conn: AsyncConnection) -> bool:
"""Does the DB have any application tables (excluding alembic_version)?"""
result = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT 1 FROM information_schema.tables "
" WHERE table_schema = 'public' "
" AND table_name NOT IN ('alembic_version')"
")"
)
)
return bool(result.scalar())
async def _db_has_alembic_version(conn: AsyncConnection) -> bool:
result = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT 1 FROM information_schema.tables "
" WHERE table_schema = 'public' AND table_name = 'alembic_version'"
")"
)
)
return bool(result.scalar())
async def run_migrations() -> None:
"""
Apply Alembic migrations up to head.
Auto-stamps pre-migration DBs (schema created by a prior `create_all` run
with no `alembic_version`) at the initial revision so subsequent migrations
(e.g. adding an enum value) apply cleanly instead of re-running 001.
Runs in a worker thread — Alembic's command.upgrade uses the sync SA API.
"""
# First: decide if we need to stamp. Done with the async engine so we
# don't need a second sync-DB round-trip inside the worker thread.
engine = get_engine()
needs_stamp = False
async with engine.connect() as conn:
has_version = await _db_has_alembic_version(conn)
if not has_version:
has_tables = await _db_has_tables(conn)
needs_stamp = has_tables
ini_path = Path(__file__).resolve().parents[2] / "alembic.ini"
initial_revision = "001_initial_schema"
def _run_alembic() -> None:
cfg = Config(str(ini_path))
cfg.set_main_option("sqlalchemy.url", settings.database_url_sync)
if needs_stamp:
# Existing schema from create_all — mark 001 as applied so the
# upgrade that follows only runs 002+.
logger.info(
"Stamping pre-migration DB at initial revision",
revision=initial_revision,
)
command.stamp(cfg, initial_revision)
command.upgrade(cfg, "head")
await asyncio.to_thread(_run_alembic)
async def init_db() -> None:
"""
Initialize the database schema by running the Alembic migration chain.
Migration 017 reconciled the chain to reproduce the FULL ORM schema, so
`alembic upgrade head` from base now builds every table/column/index AND
runs migration-embedded SEED DATA — e.g. the AI providers seeded in 004.
Building a fresh DB with a bare `create_all` (as a prior version did) skips
that seed: that is why a DB reset left `provider_configs` empty and the
Ollama-key endpoint 404'd. Always running migrations restores the seeds.
Fresh DB -> run the chain from base: full schema + seed data.
Existing -> apply pending migrations (a genuine failure is RAISED, not
masked by a silent fallback), then `create_all(checkfirst=True)`
to gap-fill any ORM table a migration didn't create.
`create_all` cannot ALTER an existing table, so an ORM column
added without a migration needs a fresh rebuild to appear.
"""
engine = get_engine()
async with engine.begin() as conn:
# pgvector must exist before tables that use the vector type
try:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
logger.info("pgvector extension enabled")
except Exception as e:
logger.warning(
"pgvector extension not available - RAG features will be disabled",
error=str(e),
)
async with engine.connect() as conn:
has_tables = await _db_has_tables(conn)
# Run the chain in both cases. run_migrations() stamps a pre-Alembic
# create_all DB at the initial revision first; a genuine failure propagates.
await run_migrations()
if has_tables:
# Existing DB: gap-fill any ORM table a migration didn't create.
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Existing DB: migrations applied + create_all gap-fill")
else:
logger.info("Fresh DB: built via migrations (full schema + seed data)")
# Dispose the async engine's connection pool. asyncpg caches enum type
# OIDs and their values at connection-establishment time; any connection
# opened BEFORE a migration that ran `ALTER TYPE ... ADD VALUE` will
# reject the new value as if it didn't exist. Disposing forces every
# subsequent request to introspect the current (post-migration) schema.
await engine.dispose()
logger.info("DB engine pool disposed to refresh asyncpg type cache")
async def drop_db() -> None:
"""
Drop all tables.
DANGEROUS: Only use in development/testing.
"""
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async def close_db() -> None:
"""Close the database connection."""
if _DbHolder.engine is not None:
await _DbHolder.engine.dispose()
_DbHolder.engine = None
_DbHolder.session_factory = None