016b348117
Phase 0 (foundation):
- Cause enum (controller_events.cause) for action attribution
- Schema: grooming_decisions audit table; workflows gains
grooming_evaluated_at + deferred_reason + deferred_at +
deferred_target_workflow_id; pulls gains touched_files
- audit_comments: CLOSE / DEFER templates + render_comment_template
- forgejo_writes: close_issue + defer_issue 5-step crash-safe protocol
(fingerprint dedup, error matrix, dry-run)
- patch_pr_state callback in forgejo_http
- grooming_config: 22-env-var frozen-dataclass config + log_effective
- pulls.touched_files cache extension (_pipeline_cache.py schema v8)
- reaper.reap_grooming_decisions audit-retention sweep
- reconciliation RESUME guard (deferred_reason)
Phase 1 (worker-queue shape, 2026-05-25):
- New state: GROOMING. New events: grooming_started, groom_verdict_
{proceed,defer,close}. 5 new transitions; all invariants still clean
- GroomingInputV1 + GroomingOutputV1 Pydantic contracts
- outcomes._map_grooming_outcome routes verdicts to state-machine events
- prefetch.build_grooming_stage_b_input + list_open_prs callback
- scheduler GROOMING -> grooming_stage_b role
- promote: cfg-gated DISCOVERED -> GROOMING when CONTROLLER_GROOMING_
ENABLED=true; issues skip grooming
- forgejo_writes decomposed: close_act/defer_act (Forgejo writes only;
state-machine already transitioned) + close_decide_and_act/
defer_decide_and_act (Phase 0 callers); _apply_workflow_transition
is underscore-private
- grooming.py library: tokenization, suspicion scoring (Jaccard +
weighted overlap), deterministic checks, action -> verdict mapping
- mcp/grooming_builder.py: 14-tool FastMCP server emits GroomingOutputV1
- .opencode/agents/grooming-stage-b.md: duplicate-detection agent
prompt (claude-haiku-4-5)
- grooming_side_effects.run_grooming_side_effects_tick: per-state tick
performs Forgejo writes after groom_verdict_{defer,close} fires.
Filters on event_type='transition' + payload.event (centralizes the
convention pending Phase 2's latest_transition_event helper)
- GroomingCallbacks frozen dataclass; loop.py + __main__.py wired
Worker role registry (single source of truth):
- worker/roles.py: WORKER_ROLES + WorkerRoleSpec + default_roles_csv
+ output_filename_for. agent_runner.ROLE_TO_MCP_MODULE / ROLE_TO_
OUTPUT_MODEL derive from it; opencode_session.agent_name_for reads
it for flat cases; all 6 prompt builders use output_filename_for;
worker --roles default = default_roles_csv(); launcher script
derives --roles via shell substitution. Cross-site invariant test
enforces alignment across 5 sites + opencode.json MCP registry.
Phase 0 silent-bug fix:
- reconciliation.py RESUME guard SELECT now includes deferred_reason
(was missing since Phase 0; guard was a silent no-op). Tightened
from getattr to attribute access to fail fast on future omissions.
Tests (1456 total, +91 grooming-specific):
- test_grooming_phase0.py: 34 tests (orchestrator matrix, crash
recovery, idempotency, dry-run)
- test_grooming_phase1.py: 60 tests (library, contracts, state
machine, outcomes, scheduler, promote, prefetch, act-variants
with signature parity, side-effect tick incl. natural-idempotency
+ executed-flag-skip + verdict-mismatch + reconciliation RESUME)
- test_mcp_builders.py TestGroomingBuilder: 29 tests (happy paths
+ 22 validation rules + Pydantic round-trip + master-tick-read-
path companion)
- test_worker_agent_runner.py TestRoleMaps: cross-role wiring
alignment + agent-prompt-vs-worker-fallback filename contract +
inspect.signature equality (close_act/defer_act vs
close_issue/defer_issue)
- test_state_machine.py: transition count 51 -> 56 +
events_from_grooming
Live-validated end-to-end on 4 staged sentinel PRs (#55-#58) in
dry_run: agent emits verdicts via MCP, state-machine transitions
fire, side-effect tick writes audit row, deferred_reason gates
reconciliation RESUME correctly.
Deferred refinements + Phase 2 prerequisite (latest_transition_event
helper) tracked in .drew/regressions-plan.md "Phase 1 follow-up
backlog".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
5.3 KiB
Python
142 lines
5.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()
|
|
|
|
# Note on transaction isolation: SQLite defaults to
|
|
# BEGIN DEFERRED, which means concurrent dequeue queries
|
|
# (``UPDATE … WHERE attempt_id = (SELECT … LIMIT 1)``) can
|
|
# race — two workers may both pick the inner SELECT before
|
|
# either UPDATE commits, and the loser hits SQLITE_BUSY (and
|
|
# waits up to ``busy_timeout``). This is **acceptable for
|
|
# single-host dev** (where SQLite is the only option) but is
|
|
# the reason MULTI-MACHINE deployments MUST use Postgres
|
|
# (which has ``FOR UPDATE SKIP LOCKED`` — see db/dequeue.py
|
|
# for the dual-dialect path). The RUNBOOK calls this out
|
|
# under "Multiple machines, but one machine takes all the
|
|
# work."
|
|
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`` and apply additive
|
|
column migrations. Idempotent — safe to call on every controller
|
|
startup; existing tables are not re-created and existing columns
|
|
are not re-added.
|
|
|
|
v1 ships without Alembic; ``create_all`` handles fresh DBs and
|
|
``apply_additive_migrations`` covers column additions on live
|
|
DBs. When the schema becomes complex enough that this pattern
|
|
breaks (rename / backfill / non-NULL with default), graduate to
|
|
Alembic and remove the migrations module.
|
|
"""
|
|
if engine is None:
|
|
engine = build_engine()
|
|
Base.metadata.create_all(engine)
|
|
from .migrations import apply_additive_migrations
|
|
|
|
apply_additive_migrations(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()
|