c8677d985d
R3 — delete unused schema columns: ``workflows.ci_flake_retries_remaining`` and ``workflows.awaiting_ci_started_at`` shipped in round-1 batch D as "staged for future tick handlers" — but no producer or reader ever used them. ci_poll.py uses ``entered_state_at`` (already populated on every transition) as the AWAITING_CI start timestamp. Delete both columns; the flake-retries counter should ship with its producer, not as speculative schema. R4 — safe_json_dumps raises on sets: Previously sets/frozensets were silently coerced to sorted lists. This contradicted the "raise loudly" design intent (JSON has no native set; a reader doing ``parsed["tags"]`` would get a list, losing set algebra). Now raises with an actionable message pointing the producer at ``sorted(list(...))`` for explicit conversion. R5 — TestSQLiteConcurrentDequeue docstring honest: The test name implied it pinned SQLite's busy_timeout retry. It doesn't — :memory: + StaticPool means both threads share one connection (SQLAlchemy serializes per-connection). Updated docstring to say what the test ACTUALLY pins (Python-level serialization safety, exactly-one-winner, clean loser-reason) and explicitly what it doesn't (cross-process SQLITE_BUSY retry, which would need file-backed SQLite + QueuePool — not shipped because multi-machine requires Postgres). R6 — safety timer on test_loop_runs_ci_poll_exhaustion_on_cadence: Previously the test relied entirely on on_iter setting stop when workflows_exhausted>0. If the logic regressed (SQL schema drift, on_iter never seeing the count), the test wedged CI indefinitely. Now armed with threading.Timer(5.0, stop.set) safety net + an assertion that surfaces the failure mode if the safety timer fired first. R8 — _json_safe scope documented honestly: The docstring claimed "everywhere the controller serializes" but the encoder is only wired at runner.py and scheduler.py — the two sites that serialize WORKER-ORIGINATED payloads. Other json.dumps call sites (event-row payloads, discovery markers) serialize fixed-shape dicts of native types and don't need restriction. Updated docstring to scope the claim accurately. R10 — missing test assertions added: - test_strict_parser_coverage_blocks_startup_with_stubs: now captures logs + asserts the operator-facing error message is emitted (so journald shows the cause; a silent rc=2 would be confusing). - test_externally_merged_takes_priority_over_label_removal: now asserts event_type="external-merge" + reason="externally-merged" (a regression that transitioned correctly with the wrong reason in the audit trail would now be caught). - test_second_pause_captures_post_resume_state (NEW): pause/resume/ pause cycle. After RESUME + workflow advances to REVIEWING, a second PAUSE must capture REVIEWING as pre_pause_state (not the original IMPLEMENTING). Round-2 coverage only exercised first pause. Total: 696 controller tests pass (+3 net from new + updated tests), 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
370 lines
15 KiB
Python
370 lines
15 KiB
Python
"""SQLAlchemy 2.0 declarative models for the controller DB.
|
|
|
|
Schema follows plan v6/v9: unified ``workflows`` table with ``kind``
|
|
discriminator (pr/issue), workflow_attempts that doubles as the worker
|
|
queue (status + lock columns), controller_events for audit + Forgejo
|
|
replay, flake_history for CI flake learning, ci_observations for raw
|
|
CI state history.
|
|
|
|
Cross-dialect (SQLite + Postgres):
|
|
- ``JSON`` columns map to JSON on SQLite and JSONB on Postgres.
|
|
- ``Integer``/``BigInteger`` are dialect-portable; we use
|
|
``BigInteger`` for PKs that will grow (attempts) and ``Integer``
|
|
for workflow ids (one-per-PR; bounded).
|
|
- Partial indexes use the SQLAlchemy ``Index(..., postgresql_where=)``
|
|
+ ``sqlite_where=`` pattern so both dialects honor them.
|
|
- ``CHECK`` constraints use SQLAlchemy ``CheckConstraint`` (both
|
|
dialects).
|
|
- Per the v6 schema decision, the parent_workflow_id kind-validation
|
|
is app-level (SQLite can't do subquery-CHECK; Postgres trigger
|
|
defer to v6 plan body).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
CheckConstraint,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
from sqlalchemy.orm import (
|
|
DeclarativeBase,
|
|
Mapped,
|
|
mapped_column,
|
|
relationship,
|
|
)
|
|
from sqlalchemy.types import JSON
|
|
|
|
|
|
# Cross-dialect JSON: JSONB on Postgres, JSON on SQLite. JSONB-only
|
|
# features (GIN indexes, jsonb_path_ops) are not used; v1 stays
|
|
# portable.
|
|
JsonColumn = JSON().with_variant(JSONB(), "postgresql")
|
|
|
|
# Cross-dialect autoincrement PK: SQLite's INTEGER (4-byte rowid alias
|
|
# that auto-increments); Postgres's BIGSERIAL (8-byte). SQLAlchemy's
|
|
# BigInteger doesn't autoincrement on SQLite, so this variant gives us
|
|
# the right type on each dialect.
|
|
AutoincrementPk = Integer().with_variant(BigInteger(), "postgresql")
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
"""SQLAlchemy 2.0 declarative base for controller tables."""
|
|
type_annotation_map = {dict[str, Any]: JsonColumn}
|
|
|
|
|
|
# ─── workflows ────────────────────────────────────────────────────────
|
|
|
|
|
|
class Workflow(Base):
|
|
"""One PR or issue under controller management.
|
|
|
|
Plan v6 unified `workflows` table (replaces v5's separate
|
|
pr_workflows / issue_workflows) with `kind` discriminator. An
|
|
issue workflow can spawn a PR workflow via `parent_workflow_id`
|
|
on the new row (CREATED_PR transition).
|
|
"""
|
|
|
|
__tablename__ = "workflows"
|
|
|
|
workflow_id: Mapped[int] = mapped_column(AutoincrementPk, primary_key=True, autoincrement=True)
|
|
|
|
# Discriminator + identity
|
|
kind: Mapped[str] = mapped_column(String(8), nullable=False) # 'pr' | 'issue'
|
|
owner: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
repo: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
entity_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
parent_workflow_id: Mapped[int | None] = mapped_column(
|
|
AutoincrementPk,
|
|
ForeignKey("workflows.workflow_id", ondelete="RESTRICT"),
|
|
nullable=True,
|
|
)
|
|
|
|
# State machine
|
|
current_state: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
current_tier: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
tier_last_succeeded: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
# Timestamps
|
|
started_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
last_transition_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
entered_state_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
|
|
# Limits + workspace
|
|
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
|
|
workspace_dir: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
# MERGING non-blocking retries
|
|
merging_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
merging_retry_next_attempt_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
# Phase 1k+: pre-pause state for label-gate PAUSE/RESUME. When the
|
|
# operator removes CONTROLLER_OPT_IN_LABEL, the workflow's current
|
|
# state moves to PAUSED and the prior state is captured here. On
|
|
# label re-add, the master reads this column to determine the
|
|
# resume target. NULL means "never paused" or "resumed".
|
|
pre_pause_state: Mapped[str | None] = mapped_column(
|
|
String(32), nullable=True
|
|
)
|
|
|
|
# Phase 1k++ (R3): the ``ci_flake_retries_remaining`` and
|
|
# ``awaiting_ci_started_at`` columns added in Phase 1k were never
|
|
# actually read or written by any producer — the round-3 review
|
|
# caught them as dead schema weight. ``ci_poll.py`` uses
|
|
# ``entered_state_at`` (already populated on every transition) as
|
|
# the AWAITING_CI start timestamp; the flake-retries counter is a
|
|
# future-phase item that should ship with its producer, not as
|
|
# speculative schema.
|
|
|
|
# Relationships
|
|
attempts: Mapped[list[WorkflowAttempt]] = relationship(
|
|
back_populates="workflow", cascade="all, delete-orphan"
|
|
)
|
|
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"kind IN ('pr', 'issue')",
|
|
name="ck_workflows_kind_valid",
|
|
),
|
|
UniqueConstraint(
|
|
"owner", "repo", "kind", "entity_number",
|
|
name="uq_workflows_entity",
|
|
),
|
|
# Filtered indexes — fast worklist queries skip terminal rows.
|
|
Index(
|
|
"ix_workflows_active",
|
|
"current_state",
|
|
postgresql_where=text(
|
|
"current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR')"
|
|
),
|
|
sqlite_where=text(
|
|
"current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR')"
|
|
),
|
|
),
|
|
Index(
|
|
"ix_workflows_merging_retry",
|
|
"merging_retry_next_attempt_at",
|
|
postgresql_where=text("current_state = 'MERGING'"),
|
|
sqlite_where=text("current_state = 'MERGING'"),
|
|
),
|
|
)
|
|
|
|
|
|
# ─── workflow_attempts (IS the worker queue) ──────────────────────────
|
|
|
|
|
|
class WorkflowAttempt(Base):
|
|
"""One attempt by one role (estimator / implementer / reviewer /
|
|
conflict_resolver / summarizer) against one workflow.
|
|
|
|
Doubles as the worker queue: workers dequeue rows where
|
|
``status='pending'`` via ``dequeue.py`` (Postgres FOR UPDATE
|
|
SKIP LOCKED, SQLite BEGIN IMMEDIATE). Lock columns (status,
|
|
locked_by_instance, locked_at, lock_heartbeat_at, lock_ttl_seconds,
|
|
pickup_count) implement the v9 multi-machine-safe lock protocol.
|
|
"""
|
|
|
|
__tablename__ = "workflow_attempts"
|
|
|
|
attempt_id: Mapped[int] = mapped_column(AutoincrementPk, primary_key=True, autoincrement=True)
|
|
workflow_id: Mapped[int] = mapped_column(
|
|
AutoincrementPk,
|
|
ForeignKey("workflows.workflow_id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
role: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
tier: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
|
|
# v5 queue + lock columns
|
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
|
locked_by_instance: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
lock_heartbeat_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
lock_ttl_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=600)
|
|
pickup_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
wallclock_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
|
|
# SHA bookkeeping
|
|
head_sha_before: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
head_sha_after: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# Payloads (JSONB on Postgres, JSON on SQLite). Application caps
|
|
# input_payload at 4MB (see payload_guard.py).
|
|
input_payload: Mapped[dict[str, Any]] = mapped_column(JsonColumn, nullable=False)
|
|
input_version: Mapped[str] = mapped_column(String(8), nullable=False)
|
|
input_payload_truncated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
output_payload: Mapped[dict[str, Any] | None] = mapped_column(JsonColumn, nullable=True)
|
|
output_version: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
|
outcome: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# Worker metadata
|
|
session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
worker_subprocess_pid: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
strict_parse_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
workflow: Mapped[Workflow] = relationship(back_populates="attempts")
|
|
|
|
__table_args__ = (
|
|
CheckConstraint(
|
|
"status IN ('pending', 'in_progress', 'complete', 'failed', 'reaped')",
|
|
name="ck_attempts_status_valid",
|
|
),
|
|
CheckConstraint("pickup_count >= 0", name="ck_attempts_pickup_count_nonneg"),
|
|
# Worker-dequeue index: hot path of every worker tick.
|
|
Index(
|
|
"ix_attempts_pending_by_role",
|
|
"role", "created_at",
|
|
postgresql_where=text("status = 'pending'"),
|
|
sqlite_where=text("status = 'pending'"),
|
|
),
|
|
# Reaper index: master tick scans this every 60s.
|
|
Index(
|
|
"ix_attempts_in_progress_by_heartbeat",
|
|
"lock_heartbeat_at",
|
|
postgresql_where=text("status = 'in_progress'"),
|
|
sqlite_where=text("status = 'in_progress'"),
|
|
),
|
|
# Master driver: scans complete attempts to advance state machine.
|
|
Index(
|
|
"ix_attempts_complete_by_workflow",
|
|
"workflow_id", "attempt_number",
|
|
postgresql_where=text("status IN ('complete', 'failed')"),
|
|
sqlite_where=text("status IN ('complete', 'failed')"),
|
|
),
|
|
)
|
|
|
|
|
|
# ─── controller_events ────────────────────────────────────────────────
|
|
|
|
|
|
class ControllerEvent(Base):
|
|
"""Audit trail + Forgejo-write-replay log.
|
|
|
|
Every state transition is a row. Forgejo writes (status comments,
|
|
labels, merges) live here so a crash between DB commit + Forgejo
|
|
write can be replayed on next master tick (v9 simplified protocol:
|
|
Forgejo-first, DB-second, reconciliation syncs DB from Forgejo).
|
|
|
|
For v1 ``forgejo_write_pending`` is unused (the v9 simplification
|
|
dropped the 3-step protocol); kept in the schema so a future
|
|
upgrade can re-enable it without a migration.
|
|
"""
|
|
|
|
__tablename__ = "controller_events"
|
|
|
|
event_id: Mapped[int] = mapped_column(AutoincrementPk, primary_key=True, autoincrement=True)
|
|
workflow_id: Mapped[int] = mapped_column(
|
|
AutoincrementPk,
|
|
ForeignKey("workflows.workflow_id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
)
|
|
ts: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
from_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
to_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
attempt_id: Mapped[int | None] = mapped_column(
|
|
AutoincrementPk,
|
|
ForeignKey("workflow_attempts.attempt_id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
payload: Mapped[dict[str, Any] | None] = mapped_column(JsonColumn, nullable=True)
|
|
|
|
# v3 write protocol fields (kept for v2-style upgrade path)
|
|
forgejo_write_pending: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
forgejo_fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
forgejo_result: Mapped[dict[str, Any] | None] = mapped_column(JsonColumn, nullable=True)
|
|
replay_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_events_pending_forgejo",
|
|
"workflow_id", "ts",
|
|
postgresql_where=text("forgejo_write_pending = true"),
|
|
sqlite_where=text("forgejo_write_pending = 1"),
|
|
),
|
|
Index("ix_events_by_workflow_ts", "workflow_id", "ts"),
|
|
)
|
|
|
|
|
|
# ─── flake_history ────────────────────────────────────────────────────
|
|
|
|
|
|
class FlakeHistory(Base):
|
|
"""CI flake learning — per-(owner, repo, context) flake-score
|
|
accumulator. Plan v6: ship empty + learning heuristic; bootstrap
|
|
via first-failure mandatory retry. flake_score = flake_count /
|
|
observations; threshold 0.6.
|
|
"""
|
|
|
|
__tablename__ = "flake_history"
|
|
|
|
owner: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
repo: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
context: Mapped[str] = mapped_column(String(256), primary_key=True)
|
|
|
|
observations: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
flake_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
flake_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
|
last_seen: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
operator_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
|
|
|
|
# ─── ci_observations ──────────────────────────────────────────────────
|
|
|
|
|
|
class CIObservation(Base):
|
|
"""Raw CI status observed for a head_sha at a point in time.
|
|
|
|
Append-only; used for parser-rot detection + flake history input
|
|
+ post-mortem debugging. Cleaned up via periodic retention sweep
|
|
(90 days).
|
|
"""
|
|
|
|
__tablename__ = "ci_observations"
|
|
|
|
observation_id: Mapped[int] = mapped_column(AutoincrementPk, primary_key=True, autoincrement=True)
|
|
head_sha: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
observed_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
state: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
contexts: Mapped[dict[str, Any]] = mapped_column(JsonColumn, nullable=False)
|
|
|
|
__table_args__ = (
|
|
Index("ix_ci_obs_by_sha", "head_sha", "observed_at"),
|
|
)
|