Files
cleveragents-core/tools/controller/db/session.py
T
drew eab476e48e feat(controller): Phase 1c — worker controller skeleton
The dequeue+lock+heartbeat+runner+loop machinery. Production
OpenCode + MCP invocation slots in via the agent_runner callable
(Phase 1c-2). This commit is the structural foundation:

tools/controller/worker/:

- identity.py: build_instance_id() → "{hostname}/{pid}/{worker_uuid}"
  per plan v9 (slash delimiter; IPv6-safe; uuid4 prefix for
  per-instance uniqueness).

- heartbeat.py: Heartbeat thread that updates lock_heartbeat_at
  every interval (default 30s). v9 simplified: TTL-only (no activity
  tracking). UPDATE … WHERE locked_by_instance=us; rowcount=0 →
  lost_lock_event.set() and thread exits, letting reaper handle it.

- runner.py: run_one_attempt() drives one attempt end-to-end.
  Starts heartbeat → invokes agent_runner → on success writes
  status='complete' + output_payload; on WorkerError writes
  status='failed' with outcome label; on WorkerLostLock or detected
  stolen-lock-at-write returns aborted (no DB write — reaper has
  already re-pended). Defense-in-depth: even if agent returns
  successfully, lost_lock_event.is_set() check skips the write.

- loop.py: worker_main_loop() polls the DB for pending attempts up
  to MAX_CONCURRENT_WORKERS_PER_MACHINE, submits each to a
  ThreadPoolExecutor. Honors stop_event for graceful shutdown
  (drains in-flight before exit).

tools/controller/db/session.py: StaticPool for in-memory SQLite so
the heartbeat thread + runner write + dequeue all see the same DB
(without this, ":memory:" gives each connection an independent DB).

16 new tests in test_worker.py: instance ID format/uniqueness;
heartbeat tick (hold + steal); runner happy path; 5 error paths
(worker error / unexpected exception / WorkerLostLock raised /
stolen lock at write / lost_lock_event set defense-in-depth); 4
loop scenarios (single attempt, role filter skip, empty queue
exit-on-stop, explicit instance_id).

Total: 157 controller tests; full auto_agents suite 2519 pass.
2026-05-18 13:08:27 -04:00

121 lines
4.3 KiB
Python

"""DB engine + session helpers.
The controller talks to SQLite (tests + local dev) or Postgres
(multi-machine production deploy). Engine selection is via
``CLEVERAGENTS_DB_URL`` env var; no URL → in-memory SQLite (test
default).
Connection lifetime: tests + master use short-lived sessions per
operation via ``session_scope`` (transactional + auto-rollback on
exception). Worker controllers (Phase 1c) hold longer-lived sessions
for the dequeue + heartbeat loop; that pattern lives in worker.py.
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from .models import Base
DEFAULT_DB_URL = "sqlite:///:memory:"
def _resolve_db_url(db_url: str | None = None) -> str:
"""Resolve the DB URL with env-var fallback."""
if db_url:
return db_url
return os.environ.get("CLEVERAGENTS_DB_URL", DEFAULT_DB_URL)
def build_engine(db_url: str | None = None) -> Engine:
"""Create a SQLAlchemy engine. Sets per-dialect tuning:
- SQLite: enable foreign keys, WAL mode, busy_timeout. ``echo``
defaults to False; flip via ``CLEVERAGENTS_DB_ECHO=1``.
- Postgres: pool_pre_ping=True so dead connections are noticed
before a tick fires against a stale conn.
"""
url = _resolve_db_url(db_url)
echo = os.environ.get("CLEVERAGENTS_DB_ECHO", "0").lower() in {"1", "true", "yes"}
if url.startswith("sqlite"):
# SQLite-specific tuning: same-thread restriction off (the
# controller's worker pool uses threads) + reasonable busy
# timeout. ``check_same_thread=False`` is safe because each
# thread acquires its own Session from sessionmaker; SQLAlchemy
# serializes per-connection work.
#
# For in-memory SQLite (``:memory:``), StaticPool routes every
# connection to the same underlying DB so the heartbeat thread
# + the runner's write + the dequeue all see the same data.
# Without it, ``:memory:`` gives each connection its own
# DB — the test harness sees ghost rows.
is_in_memory = ":memory:" in url
connect_args: dict[str, object] = {
"check_same_thread": False, "timeout": 30,
}
engine_kwargs: dict[str, object] = {"echo": echo, "connect_args": connect_args}
if is_in_memory:
engine_kwargs["poolclass"] = StaticPool
engine = create_engine(url, **engine_kwargs)
# WAL + foreign keys + reasonable defaults on every new
# connection. The ``connect`` event fires per-connection.
from sqlalchemy import event
@event.listens_for(engine, "connect")
def _sqlite_pragmas(conn, _record):
cur = conn.cursor()
cur.execute("PRAGMA foreign_keys = ON")
cur.execute("PRAGMA journal_mode = WAL")
cur.execute("PRAGMA synchronous = NORMAL")
cur.execute("PRAGMA busy_timeout = 5000")
cur.close()
return engine
# Postgres + others
return create_engine(url, echo=echo, pool_pre_ping=True)
def create_all(engine: Engine | None = None) -> None:
"""Create all controller tables on ``engine``. Idempotent — safe
to call on every controller startup; existing tables are not
re-created.
For schema-migration work (alembic), use ``alembic upgrade head``
instead. v1 ships with ``create_all`` since the schema isn't
versioned yet.
"""
if engine is None:
engine = build_engine()
Base.metadata.create_all(engine)
@contextmanager
def session_scope(engine: Engine | None = None) -> Iterator[Session]:
"""Transactional session context.
Usage:
with session_scope(engine) as session:
session.add(workflow)
# auto-commits on clean exit; rolls back on exception.
Each ``with`` block gets a fresh session. SQLAlchemy's session
factory handles connection pooling.
"""
if engine is None:
engine = build_engine()
factory = sessionmaker(engine, expire_on_commit=False)
session = factory()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()