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>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""Additive schema migrations for the controller DB.
|
|
|
|
The project does not run Alembic — ``create_all`` (declarative
|
|
``Base.metadata.create_all``) handles schema for fresh databases.
|
|
That covers test DBs and first-time installs cleanly, but adds
|
|
nothing to a DB that already has the table from a prior version.
|
|
|
|
When a column is added to an existing model, live deployments need
|
|
an additive migration. We keep this minimal: idempotent ``ALTER
|
|
TABLE ... ADD COLUMN ...`` per dialect, wired into ``create_all``
|
|
so the next controller startup picks it up.
|
|
|
|
This is NOT a versioned migration framework. Each step is a single
|
|
idempotent SQL statement that assumes the column either does not
|
|
exist (then add it) or already exists (then no-op). Order does not
|
|
matter; the steps are independent.
|
|
|
|
When the schema becomes complex enough that this pattern breaks
|
|
(e.g. needing data backfills or renames), graduate to Alembic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import NamedTuple
|
|
|
|
from sqlalchemy import Engine, text
|
|
from sqlalchemy.exc import OperationalError, ProgrammingError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _AdditiveColumn(NamedTuple):
|
|
"""An additive column migration step.
|
|
|
|
``sqlite_decl`` is the type clause for SQLite (e.g. ``"TEXT"``).
|
|
``postgres_decl`` is the type clause for Postgres (e.g. ``"VARCHAR(32)"``).
|
|
Both are NULL-allowed (the table already has rows; a non-NULL
|
|
column would require a backfill, which this module does not do).
|
|
"""
|
|
|
|
table: str
|
|
column: str
|
|
sqlite_decl: str
|
|
postgres_decl: str
|
|
|
|
|
|
# Append new column migrations here. Each step is independent + idempotent.
|
|
_ADDITIVE_COLUMNS: tuple[_AdditiveColumn, ...] = (
|
|
# Phase 0 (grooming plan): controller_events.cause for action
|
|
# attribution. See contracts/causes.py and the plan's decision #23.
|
|
_AdditiveColumn(
|
|
table="controller_events",
|
|
column="cause",
|
|
sqlite_decl="TEXT",
|
|
postgres_decl="VARCHAR(32)",
|
|
),
|
|
# Phase 0 (grooming plan): workflows columns for one-shot semantics
|
|
# and the defer block. See decisions #15, #16, #17 and the Phase 1
|
|
# schema-additions section (pulled forward to Phase 0 because the
|
|
# close_issue/defer_issue callbacks depend on them).
|
|
_AdditiveColumn(
|
|
table="workflows",
|
|
column="grooming_evaluated_at",
|
|
sqlite_decl="TEXT",
|
|
postgres_decl="TIMESTAMP WITH TIME ZONE",
|
|
),
|
|
_AdditiveColumn(
|
|
table="workflows",
|
|
column="deferred_reason",
|
|
sqlite_decl="TEXT",
|
|
postgres_decl="VARCHAR(32)",
|
|
),
|
|
_AdditiveColumn(
|
|
table="workflows",
|
|
column="deferred_at",
|
|
sqlite_decl="TEXT",
|
|
postgres_decl="TIMESTAMP WITH TIME ZONE",
|
|
),
|
|
_AdditiveColumn(
|
|
table="workflows",
|
|
column="deferred_target_workflow_id",
|
|
sqlite_decl="INTEGER",
|
|
postgres_decl="INTEGER",
|
|
),
|
|
)
|
|
|
|
|
|
def apply_additive_migrations(engine: Engine) -> None:
|
|
"""Apply every additive column migration that is missing.
|
|
|
|
Idempotent: re-running on a fully-up-to-date DB is a no-op
|
|
(each step swallows the dialect-specific "column already exists"
|
|
error). Safe to call from ``create_all``.
|
|
"""
|
|
dialect = engine.dialect.name # 'sqlite' or 'postgresql'
|
|
with engine.begin() as conn:
|
|
for step in _ADDITIVE_COLUMNS:
|
|
if dialect == "postgresql":
|
|
# Postgres 9.6+ has IF NOT EXISTS for ADD COLUMN.
|
|
conn.execute(
|
|
text(
|
|
f"ALTER TABLE {step.table} "
|
|
f"ADD COLUMN IF NOT EXISTS {step.column} "
|
|
f"{step.postgres_decl}"
|
|
)
|
|
)
|
|
logger.debug(
|
|
"migrations: ensured column %s.%s (postgres)",
|
|
step.table,
|
|
step.column,
|
|
)
|
|
continue
|
|
|
|
# SQLite has no IF NOT EXISTS for ADD COLUMN before 3.35;
|
|
# catch the duplicate-column OperationalError instead.
|
|
try:
|
|
conn.execute(
|
|
text(
|
|
f"ALTER TABLE {step.table} "
|
|
f"ADD COLUMN {step.column} {step.sqlite_decl}"
|
|
)
|
|
)
|
|
logger.info(
|
|
"migrations: added column %s.%s (sqlite)",
|
|
step.table,
|
|
step.column,
|
|
)
|
|
except (OperationalError, ProgrammingError) as exc:
|
|
msg = str(exc).lower()
|
|
if "duplicate column" in msg or "already exists" in msg:
|
|
logger.debug(
|
|
"migrations: column %s.%s already present (sqlite)",
|
|
step.table,
|
|
step.column,
|
|
)
|
|
continue
|
|
# Any other OperationalError is a real failure — let it surface.
|
|
raise
|
|
|
|
|
|
__all__ = ["apply_additive_migrations"]
|