2cbe62a70c
Bundles a long-overdue set of fixes that surfaced while watching the single-PR pipeline test against PR #30 the morning of 2026-05-07. # F1 — long-worker liveness contract The dispatcher heartbeat was only refreshed *between* worker sessions. On a 30-minute review the heartbeat file went stale, and any heartbeat-watchdog (dispatchers-launcher.sh / cleveragents-dispatchers.service) would SIGTERM a perfectly-healthy worker mid-cycle, orphaning the OpenCode session and the auto/claimed-* lock. ``_opencode_worker.run_session_blocking`` now accepts an ``on_poll`` callback fired once per status-poll iteration; ``_dispatch_runtime.dispatch_one`` and ``conflict_drive.py`` wire it to ``write_heartbeat(cfg.heartbeat_path)``. Callback exceptions are logged and swallowed so a transient EROFS on the heartbeat path can never mask a successful worker completion. # F2 — in-flight cycle visibility (schema v5) The ``dispatch_*_cycles`` tables previously only recorded a row at cycle *end*. While a worker was running, the operator's only signal was the heartbeat file — and even that became stale (see F1). Schema bumped to v5: ``ended_at`` is now nullable and ``cycle_id`` carries a UNIQUE index. ``begin_cycle`` writes the in-flight row at start; ``finish_cycle`` updates it at end. ``run_one_cycle``'s try/finally guarantees ``finish_cycle`` runs even when ``collect_candidates`` / ``dispatch_one`` raises, so an orphan ``ended_at IS NULL`` can no longer be stuck forever after a crash. The v4→v5 migration is now defined in ONE place — a set of helpers in ``_forgejo_cache.py`` (``DISPATCH_CYCLE_TABLES``, ``_dispatch_cycle_create_sql``, ``_dispatch_cycle_index_sqls``, ``migrate_dispatch_cycle_table_to_v5``, ``ensure_dispatch_cycle_schema``). Both ``ForgejoCache._migrate_to_v5_in_flight_rows`` and ``_dispatch_runtime.ensure_cycle_table`` import from there, eliminating the drift risk of the previous duplicated DDL. Pre-existing rows are preserved verbatim across the migration. # F3 — telemetry surface for the new state ``/api/health`` now returns ``in_flight_cycle: {cycle_id, started_at, session_id, candidates_count, elapsed_s}`` per dispatcher daemon and a ``running_long_worker: bool`` flag (heartbeat older than 600s AND a matching pid alive — should never fire under healthy F1 operation, so when it does it points at a real bug). The Drivers and Overview tabs in ``.opencode/telemetry/{index.html,app.js,style.css}`` render in-flight rows with a tinted background + "in flight" pill, daemon tiles get a dashed border for the long-worker state, and each tile shows the running cycle's id + elapsed time inline. # Fix A — reasoningEffort high → medium for pr-review-worker On its own that change alone would not have been enough, but combined with Fix C below it dropped a representative cycle from "timed out at 30:00" to a target ~2-3min. Pure config change in ``.opencode/agents/pr-review-worker.md``; no code path touched. # Fix C — pre-fetch PR diff in dispatch_review and embed in prompt The reviewer used to spawn a ``git-isolator-util`` subagent, which shelled out to ``git clone``, ``git fetch``, and ``git diff master...HEAD``. That subagent burned 90+ seconds and several token budgets per cycle. ``dispatch_review.py`` now fetches the unified diff via the Forgejo ``/pulls/{n}.diff`` endpoint and embeds it into the worker prompt under an ``UNTRUSTED CONTENT`` fence with explicit BEGIN_PR_DIFF / END_PR_DIFF markers, head_sha pinning, character-count metadata, and END marker redaction to defeat patch-text injection. The worker is instructed to use the embedded diff and skip the isolator subagent entirely when it is present. Falls back to the old path on fetch failure or via the ``REVIEW_DISPATCHER_EMBED_DIFF=0`` env switch. # Cross-cutting bash rules The ``pr-review-worker``'s shell tool calls kept hitting ``permission denied`` because OpenCode's permission engine matches the *raw, unexpanded* command string against allow-globs. Chained commands (``&&``, ``||``, ``;``, ``|``), command substitution (``$(...)``), bare variable assignments, multi-line continuations (``\\\n``), heredocs, and inline ``python3 -c "..."`` strings all contain characters the permission glob cannot span, and were silently denied. Added ``.opencode/instructions/bash-commands.md`` (wired into ``opencode.json`` via the ``instructions`` array so it appends to EVERY agent's system prompt globally), with hard rules + recovery recipes (``printf > /tmp/file`` instead of heredocs; ``printf > /tmp/script.py`` + ``python3 /tmp/script.py`` instead of ``python3 -c``; ``curl -d @/tmp/body.json`` instead of multi-line ``-d '{...}'``). # Pre-commit polish (architect/dev/test review) Surfaced during a chief-architect / principal-developer / senior-test-engineer code review of the uncommitted change: - Schema DDL deduplication (described above under F2). - ``finish_cycle`` INSERT-fallback now preserves ``started_at`` / ``driver_name`` when caller provides them; otherwise stamps a ``synthetic_started_at: true`` flag in the raw blob so cycle-time analytics can exclude rows whose duration was synthesised. - ``bytes=`` → ``chars=`` in the embedded-diff header. The value is ``len(diff_text)`` after ``decode("utf-8")`` — a UTF-8 character count, not a byte count. Off-by-multibyte for non-ASCII patches. - ``scripts/opencode-builder.sh`` mode 644 → 755. - ``.gitignore`` entries for ``.parked-prs.json`` (runtime state for ``tools/park_other_prs.py --restore``) and ``.dispatcher-logs/`` (append-only local pipeline log directory). # Tests (381 passed, 1 skipped) - ``test_opencode_worker.py``: 3 new tests for ``on_poll`` cadence, error swallowing, and backwards-compatible default. - ``test_dispatch_runtime.py``: 7 new tests for ``begin_cycle`` / ``finish_cycle`` semantics, the v4→v5 migration with row preservation, the crash-safe try/finally path, the ``dispatch_one`` → ``run_session_blocking`` ``on_poll`` wiring, and the new ``started_at`` / ``driver_name`` plumbing through the INSERT-fallback branch. - ``test_telemetry_server.py``: 4 new tests for ``in_flight_cycle`` in ``/api/health``, the elapsed-seconds computation, and the ``running_long_worker`` flag. - ``test_telemetry_schema.py``: assertion bumped from v4 → v5 and a new test confirming the cycle tables now allow ``ended_at IS NULL``. - ``test_dispatch_review.py`` (new file): 14 tests for diff fetch (happy path, truncation, HTTP/URL errors, END_PR_DIFF redaction, Forgejo auth scheme), ``_build_diff_section`` (dry-run, env toggle, embedding, fallback), and end-to-end prompt embedding. Co-authored-by: Cursor <cursoragent@cursor.com>
1390 lines
59 KiB
Python
1390 lines
59 KiB
Python
"""Local SQLite cache of Forgejo data for the auto-agents repo (defaults
|
|
to ``cleveragents/cleveragents-core``; overridable via ``FORGEJO_OWNER``
|
|
and ``FORGEJO_REPO``).
|
|
|
|
Used by ``count-master-merges.py`` and ``pr-stats.py`` to answer all
|
|
master-branch and PR-lifecycle questions from a local database, syncing only
|
|
deltas from the API instead of re-fetching the whole window on every run.
|
|
|
|
Design
|
|
------
|
|
- **Single SQLite file** at ``tools/.cache/forgejo.sqlite`` (gitignored).
|
|
- **Three data tables**:
|
|
``commits`` - master branch, keyed by full SHA, append-only
|
|
``pulls`` - every PR we've seen, keyed by PR number
|
|
``reachability`` - cached compare/master...sha results
|
|
Plus ``sync_meta`` for cursors and ``schema_version`` for migrations.
|
|
- **Every row stores the raw API JSON** in a ``raw`` column so new fields can
|
|
be surfaced without a cache rebuild; parsed columns are indexed
|
|
projections for efficient queries.
|
|
- **Delta sync** walks the commits and PR endpoints from page 1 (newest
|
|
first) and stops as soon as the current page is entirely already-cached
|
|
(commits) or its max ``updated_at`` is older than the last sync
|
|
(PRs). Initial seeding is just "delta sync from an empty cache" and
|
|
continues until the API pages are exhausted.
|
|
- **PR enrichment** (``merged_by``) is lazy: on read, any terminal-state PR
|
|
that lacks ``merged_by_login`` but needs it gets one PR-detail fetch,
|
|
cached permanently.
|
|
- **Concurrency**: ``fcntl.flock`` on ``forgejo.sqlite.lock`` during syncs.
|
|
SQLite handles concurrent reads natively.
|
|
- **Force-push detection**: sync_meta tracks ``master_head_sha``; if the
|
|
previous HEAD is not seen anywhere in the first N pages of current master,
|
|
we emit a warning. The user can pass ``--full`` to rebuild.
|
|
|
|
Public API (used by the scripts)
|
|
--------------------------------
|
|
::
|
|
|
|
cache = ForgejoCache.open()
|
|
cache.sync(token) # delta-sync forward
|
|
commits = cache.commits_on_master(start, end) # list of raw commit dicts
|
|
closed = cache.pulls_closed_in_window(start, end)
|
|
merged = cache.pulls_merged_in_window(start, end)
|
|
opened = cache.pulls_opened_in_window(start, end)
|
|
sha_map = cache.sha_to_pr_map(start, end)
|
|
reach = cache.is_sha_reachable_from_master(sha, token)
|
|
detail = cache.enrich_pr(number, token)
|
|
|
|
All list/map queries return the raw PR/commit dicts exactly as the Forgejo
|
|
API returned them (deserialised from the ``raw`` column), so callers that
|
|
already expect raw API shape need no changes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
# ─── Config ─────────────────────────────────────────────────────────────────
|
|
|
|
# Env-driven so fork-mode reporting (`FORGEJO_OWNER=drew` etc.) reads
|
|
# from the fork's data instead of the canonical repo. The defaults
|
|
# preserve the original behaviour: when run without env vars in a
|
|
# canonical-mode shell, the cache, every API call, and every cache file
|
|
# resolve exactly as before.
|
|
REPO_OWNER = os.environ.get("FORGEJO_OWNER", "cleveragents")
|
|
REPO_NAME = os.environ.get("FORGEJO_REPO", "cleveragents-core")
|
|
API_BASE = os.environ.get(
|
|
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1"
|
|
).rstrip("/")
|
|
REQUEST_TIMEOUT_SEC = 30
|
|
REQUEST_RETRIES = 3
|
|
RETRY_BACKOFF_SEC = 2.0
|
|
|
|
DEFAULT_CACHE_DIR = Path(__file__).resolve().parent / ".cache"
|
|
# Cache path is partitioned per-(owner, repo) so a fork-mode run never
|
|
# clobbers the canonical cache (and vice versa). The default
|
|
# ``cleveragents/cleveragents-core`` keeps the historical filename
|
|
# ``forgejo.sqlite`` for backward compatibility with existing checkouts.
|
|
if REPO_OWNER == "cleveragents" and REPO_NAME == "cleveragents-core":
|
|
DEFAULT_CACHE_PATH = DEFAULT_CACHE_DIR / "forgejo.sqlite"
|
|
else:
|
|
_safe_owner = re.sub(r"[^a-zA-Z0-9._-]+", "-", REPO_OWNER)
|
|
_safe_repo = re.sub(r"[^a-zA-Z0-9._-]+", "-", REPO_NAME)
|
|
DEFAULT_CACHE_PATH = (
|
|
DEFAULT_CACHE_DIR / f"forgejo.{_safe_owner}.{_safe_repo}.sqlite"
|
|
)
|
|
# Schema versions:
|
|
# 1 — original (commits, pulls, reachability, sync_meta)
|
|
# 2 — Tier 1B telemetry: merge_cycle, ci_gate_events, llm_activity
|
|
# 3 — Tier 1.5 telemetry: conflict_drive_cycles
|
|
# 4 — Tier 2 telemetry: dispatch_review_cycles, dispatch_implementer_cycles
|
|
# 5 — In-flight row visibility (2026-05-07): drop the NOT NULL
|
|
# constraint on ``ended_at`` for ``dispatch_review_cycles`` and
|
|
# ``dispatch_implementer_cycles`` so the dispatchers can insert a
|
|
# row at cycle start (``ended_at IS NULL``) and update it on
|
|
# completion. The migration recreates each affected table only
|
|
# when its ``ended_at`` column still carries the old NOT NULL
|
|
# constraint; pre-existing rows are preserved verbatim.
|
|
SCHEMA_VERSION = 5
|
|
|
|
|
|
# ─── Tier 2 dispatcher cycle-table schema (shared, single source of truth) ──
|
|
#
|
|
# The two dispatcher cycle tables (``dispatch_review_cycles``,
|
|
# ``dispatch_implementer_cycles``) share the same shape and the same
|
|
# v4→v5 migration. Both ``ForgejoCache._migrate_to_v5_in_flight_rows``
|
|
# and ``_dispatch_runtime.ensure_cycle_table`` need this DDL — the
|
|
# former runs when the cache file is opened through ``ForgejoCache``,
|
|
# the latter when the dispatcher writes a cycle row via raw
|
|
# ``sqlite3.connect`` and may run *before* anyone opens the cache via
|
|
# the high-level wrapper. Defining the DDL twice was a real drift risk
|
|
# (a future column add to the cycle table would have to be made in two
|
|
# places, in lockstep). Instead, both sites import from here.
|
|
DISPATCH_CYCLE_TABLES: tuple[str, ...] = (
|
|
"dispatch_review_cycles",
|
|
"dispatch_implementer_cycles",
|
|
)
|
|
|
|
|
|
def _dispatch_cycle_create_sql(table_name: str) -> str:
|
|
"""``CREATE TABLE IF NOT EXISTS`` for a v5 dispatch cycle table.
|
|
|
|
``ended_at`` is nullable so the dispatcher can insert a cycle row
|
|
at start (``ended_at IS NULL`` ⇒ in flight) and update it on
|
|
completion. The trailing UNIQUE INDEX on ``cycle_id`` is created
|
|
separately by :func:`_dispatch_cycle_index_sqls`.
|
|
"""
|
|
if table_name not in DISPATCH_CYCLE_TABLES:
|
|
raise ValueError(f"unexpected dispatch table: {table_name}")
|
|
return f"""
|
|
CREATE TABLE IF NOT EXISTS {table_name} (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cycle_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
driver TEXT NOT NULL,
|
|
candidates_count INTEGER NOT NULL,
|
|
claims_acquired INTEGER NOT NULL,
|
|
swept_count INTEGER NOT NULL,
|
|
processed_count INTEGER NOT NULL,
|
|
terminal_state TEXT,
|
|
worker_outcome TEXT,
|
|
session_id TEXT,
|
|
worker_wallclock_seconds REAL,
|
|
raw TEXT NOT NULL
|
|
)
|
|
"""
|
|
|
|
|
|
def _dispatch_cycle_index_sqls(table_name: str) -> tuple[str, ...]:
|
|
"""Index DDL for a v5 dispatch cycle table.
|
|
|
|
Three indexes:
|
|
- ``idx_<t>_started_at`` — common ORDER BY for the telemetry tables.
|
|
- ``idx_<t>_terminal`` — used when filtering by terminal_state.
|
|
- ``uniq_<t>_cycle_id`` — UNIQUE: prevents accidental double-INSERT
|
|
from a re-entrant ``begin_cycle``; required for the in-flight
|
|
visibility contract.
|
|
"""
|
|
if table_name not in DISPATCH_CYCLE_TABLES:
|
|
raise ValueError(f"unexpected dispatch table: {table_name}")
|
|
return (
|
|
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_started_at "
|
|
f"ON {table_name}(started_at)",
|
|
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_terminal "
|
|
f"ON {table_name}(terminal_state)",
|
|
f"CREATE UNIQUE INDEX IF NOT EXISTS uniq_{table_name}_cycle_id "
|
|
f"ON {table_name}(cycle_id)",
|
|
)
|
|
|
|
|
|
def migrate_dispatch_cycle_table_to_v5(
|
|
conn: sqlite3.Connection, table_name: str
|
|
) -> bool:
|
|
"""Rebuild ``table_name`` with the v5 schema (``ended_at`` nullable).
|
|
|
|
Returns ``True`` if the rebuild ran (the table existed with a v4
|
|
``ended_at NOT NULL`` constraint) or ``False`` if it was a no-op
|
|
(table missing or already v5). Pre-existing rows are preserved
|
|
verbatim and indexes are recreated as part of the rebuild.
|
|
|
|
Caller is responsible for transaction boundaries (the function
|
|
uses ``executescript`` which auto-commits, but does not start an
|
|
explicit transaction). Designed to be safely runnable from both
|
|
a ``ForgejoCache`` migration step and a raw
|
|
``sqlite3.connect`` ``ensure_cycle_table`` invocation.
|
|
"""
|
|
if table_name not in DISPATCH_CYCLE_TABLES:
|
|
raise ValueError(f"unexpected dispatch table: {table_name}")
|
|
cols = list(conn.execute(f"PRAGMA table_info({table_name})"))
|
|
if not cols:
|
|
return False
|
|
ended_at_col = next((c for c in cols if c["name"] == "ended_at"), None)
|
|
if ended_at_col is None or ended_at_col["notnull"] == 0:
|
|
return False
|
|
# Inside the rebuild we use plain ``CREATE TABLE`` (the IF NOT
|
|
# EXISTS form would also work because the table was just renamed,
|
|
# but a plain ``CREATE TABLE`` is more honest about intent).
|
|
create_inner = _dispatch_cycle_create_sql(table_name).replace(
|
|
"CREATE TABLE IF NOT EXISTS", "CREATE TABLE", 1
|
|
)
|
|
indexes = ";\n ".join(_dispatch_cycle_index_sqls(table_name))
|
|
conn.executescript(
|
|
f"""
|
|
ALTER TABLE {table_name} RENAME TO {table_name}_v4;
|
|
{create_inner};
|
|
INSERT INTO {table_name} SELECT * FROM {table_name}_v4;
|
|
DROP TABLE {table_name}_v4;
|
|
{indexes};
|
|
"""
|
|
)
|
|
return True
|
|
|
|
|
|
def ensure_dispatch_cycle_schema(
|
|
conn: sqlite3.Connection, table_name: str
|
|
) -> None:
|
|
"""Create the v5 dispatch cycle table + indexes, migrating from v4
|
|
if needed. Idempotent and self-healing.
|
|
|
|
Used by both ``_dispatch_runtime.ensure_cycle_table`` (raw
|
|
sqlite3.connect path) and ``ForgejoCache._migrate_to_v5_in_flight_rows``
|
|
(high-level wrapper path) so the schema can never drift between
|
|
the two write paths.
|
|
"""
|
|
if table_name not in DISPATCH_CYCLE_TABLES:
|
|
raise ValueError(f"unexpected dispatch table: {table_name}")
|
|
conn.execute(_dispatch_cycle_create_sql(table_name))
|
|
migrate_dispatch_cycle_table_to_v5(conn, table_name)
|
|
for idx_sql in _dispatch_cycle_index_sqls(table_name):
|
|
conn.execute(idx_sql)
|
|
|
|
|
|
# Safety ceiling on pagination during a single sync pass.
|
|
SYNC_PAGE_HARD_LIMIT = 4000 # 4000 * 50 = 200,000 rows; far above anything real.
|
|
|
|
# How many consecutive pages with no new rows before we declare the sync done.
|
|
SYNC_EMPTY_PAGE_TOLERANCE = 2
|
|
|
|
|
|
# ─── HTTP helper (mirrors count-master-merges.py) ──────────────────────────
|
|
|
|
def api_get(url: str, token: str) -> Any:
|
|
"""GET a Forgejo API URL with retries on transient network errors."""
|
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
|
last_err: Exception | None = None
|
|
for attempt in range(1, REQUEST_RETRIES + 1):
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as resp:
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError:
|
|
# HTTP errors are authoritative: propagate so caller can decide
|
|
# (e.g. 404 on compare endpoint means "not reachable").
|
|
raise
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
last_err = e
|
|
if attempt < REQUEST_RETRIES:
|
|
time.sleep(RETRY_BACKOFF_SEC * attempt)
|
|
continue
|
|
raise RuntimeError(f"cannot reach {url} after {REQUEST_RETRIES} attempts: {last_err}")
|
|
|
|
|
|
# ─── Cache ─────────────────────────────────────────────────────────────────
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
version INTEGER PRIMARY KEY
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS commits (
|
|
sha TEXT PRIMARY KEY,
|
|
committer_date TEXT NOT NULL,
|
|
author_name TEXT,
|
|
committer_name TEXT,
|
|
message TEXT,
|
|
parents TEXT,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_commits_committer_date ON commits(committer_date);
|
|
|
|
CREATE TABLE IF NOT EXISTS pulls (
|
|
number INTEGER PRIMARY KEY,
|
|
state TEXT,
|
|
merged INTEGER,
|
|
created_at TEXT,
|
|
updated_at TEXT,
|
|
closed_at TEXT,
|
|
merged_at TEXT,
|
|
base_ref TEXT,
|
|
head_sha TEXT,
|
|
merge_commit_sha TEXT,
|
|
user_login TEXT,
|
|
merged_by_login TEXT,
|
|
additions INTEGER,
|
|
deletions INTEGER,
|
|
changed_files INTEGER,
|
|
labels TEXT,
|
|
has_detail INTEGER NOT NULL DEFAULT 0,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_closed_at ON pulls(closed_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_merged_at ON pulls(merged_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_created_at ON pulls(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_updated_at ON pulls(updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_head_sha ON pulls(head_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_merge_commit_sha ON pulls(merge_commit_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_pulls_state_merged ON pulls(state, merged);
|
|
|
|
CREATE TABLE IF NOT EXISTS reachability (
|
|
merge_sha TEXT PRIMARY KEY,
|
|
reachable INTEGER NOT NULL,
|
|
checked_at TEXT NOT NULL,
|
|
master_head_sha TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
|
|
-- ─── Tier 1B telemetry tables ────────────────────────────────────────────
|
|
--
|
|
-- merge_cycle: one row per merge driver attempt (single-PR or train-merge).
|
|
--
|
|
-- Phase columns are wall-clock seconds; NULL means the phase did not run
|
|
-- for this terminal_state. Action columns let the canvas surface "what
|
|
-- the driver actually did" without having to grep logs.
|
|
CREATE TABLE IF NOT EXISTS merge_cycle (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
pr_numbers TEXT NOT NULL, -- JSON array of PR numbers
|
|
train_id TEXT, -- e.g. "auto/train/<ts>" or NULL for single-PR
|
|
bisect_depth INTEGER NOT NULL DEFAULT 0,
|
|
rebase_seconds REAL,
|
|
ci_seconds REAL,
|
|
merge_seconds REAL,
|
|
total_seconds REAL,
|
|
terminal_state TEXT NOT NULL, -- merged | merged_in_train | rebase-conflict-vs-master
|
|
-- | ci-fail-on-rebased-sha | ci-timeout | bisect-budget-exhausted
|
|
-- | restart-budget-exhausted | umbrella-pr-creation-failed
|
|
-- | merge-409-race | crashed
|
|
action_taken TEXT, -- short slug e.g. "merged"|"released"|"escalated"
|
|
action_detail TEXT, -- free-form JSON or string with details
|
|
last_released_at TEXT, -- when the PR(s) was released back to the pool
|
|
flake_rate REAL, -- forward-fill from ci_gate_events for this cycle, if computed
|
|
raw TEXT -- optional JSON blob with extra fields for debug
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_started_at ON merge_cycle(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_ended_at ON merge_cycle(ended_at);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_terminal ON merge_cycle(terminal_state);
|
|
CREATE INDEX IF NOT EXISTS idx_merge_cycle_pr_numbers ON merge_cycle(pr_numbers);
|
|
|
|
-- ci_gate_events: one row per individual CI gate observation (per-PR per-run).
|
|
--
|
|
-- gate is the status_check_context name (e.g. "CI / lint*"), outcome is
|
|
-- "passed" | "failed" | "timeout" | "skipped". Used for flake-rate, gate
|
|
-- fall-through analysis, and feeding the future T5A duplicate-failure
|
|
-- clusterer.
|
|
CREATE TABLE IF NOT EXISTS ci_gate_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
observed_at TEXT NOT NULL,
|
|
pr_number INTEGER,
|
|
head_sha TEXT NOT NULL,
|
|
gate TEXT NOT NULL,
|
|
outcome TEXT NOT NULL, -- passed | failed | timeout | skipped
|
|
duration_s REAL,
|
|
cycle_id INTEGER, -- FK to merge_cycle.id (nullable)
|
|
raw TEXT -- optional log/error excerpt
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_observed ON ci_gate_events(observed_at);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_gate ON ci_gate_events(gate);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_outcome ON ci_gate_events(outcome);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_head_sha ON ci_gate_events(head_sha);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_pr ON ci_gate_events(pr_number);
|
|
CREATE INDEX IF NOT EXISTS idx_ci_gate_events_cycle ON ci_gate_events(cycle_id);
|
|
|
|
-- llm_activity: one row per LLM call by any agent in the auto-agents pipeline.
|
|
--
|
|
-- Both for cost accounting (T1A/T1C/T2C in the token-reduction plan) and
|
|
-- for activity-stream rendering. tokens_in/tokens_out/cached_tokens are
|
|
-- denormalised here for fast aggregation.
|
|
CREATE TABLE IF NOT EXISTS llm_activity (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT,
|
|
agent TEXT NOT NULL, -- e.g. "pr-review-worker", "task-implementor"
|
|
pr_number INTEGER,
|
|
session_tag TEXT, -- AUTO-* session tag, when applicable
|
|
model TEXT NOT NULL,
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
cached_tokens INTEGER,
|
|
decision TEXT, -- e.g. "approved", "request_changes", "ci_fix_pushed"
|
|
outcome TEXT, -- e.g. "success", "escalated", "timeout"
|
|
cycle_id INTEGER, -- FK to merge_cycle.id (nullable)
|
|
raw TEXT -- optional JSON blob (full request/response trace)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_started ON llm_activity(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_agent ON llm_activity(agent);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_pr ON llm_activity(pr_number);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_model ON llm_activity(model);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_outcome ON llm_activity(outcome);
|
|
CREATE INDEX IF NOT EXISTS idx_llm_activity_cycle ON llm_activity(cycle_id);
|
|
|
|
-- ─── conflict_drive_cycles (Tier 1.5 — conflict driver telemetry) ────────
|
|
--
|
|
-- One row per (cycle, PR). The cycle-level totals (candidates_count,
|
|
-- resolved_count, escalated_count, timeout_count, push_rejected_count)
|
|
-- are duplicated across all PR rows from the same cycle for cheap
|
|
-- per-cycle aggregation; the per-PR specifics live in details_json.
|
|
--
|
|
-- ``outcome`` and ``failure_kind`` are promoted to top-level columns so
|
|
-- the retry-budget query at § 5.1 of conflict-drive-plan.md can use the
|
|
-- ``(pr_number, started_at)`` index directly without parsing JSON.
|
|
CREATE TABLE IF NOT EXISTS conflict_drive_cycles (
|
|
cycle_id TEXT NOT NULL, -- shared across rows from one cycle
|
|
pr_number INTEGER NOT NULL, -- top-level for indexed lookups
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT NOT NULL,
|
|
outcome TEXT NOT NULL, -- 'resolved' | 'resolved-no-conflict'
|
|
-- | 'unresolvable' | 'verification-fail'
|
|
-- | 'rebase-failed' | 'timeout'
|
|
-- | 'transport-error' | 'lease-violation'
|
|
failure_kind TEXT, -- 'definite' | 'transient' | NULL on success
|
|
candidates_count INTEGER NOT NULL, -- cycle-level totals duplicated across PR rows
|
|
resolved_count INTEGER NOT NULL,
|
|
escalated_count INTEGER NOT NULL,
|
|
timeout_count INTEGER NOT NULL,
|
|
push_rejected_count INTEGER NOT NULL,
|
|
details_json TEXT NOT NULL, -- per-PR breakdown
|
|
PRIMARY KEY (cycle_id, pr_number)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cdc_pr_number_started ON conflict_drive_cycles(pr_number, started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_cdc_started_at ON conflict_drive_cycles(started_at);
|
|
|
|
-- ─── Tier 2 dispatcher telemetry ─────────────────────────────────────────
|
|
--
|
|
-- One row per dispatcher cycle. ``raw`` contains the per-group counts, swept
|
|
-- claims, and per-item worker details; the promoted columns cover dashboard
|
|
-- and operator queries without JSON parsing.
|
|
CREATE TABLE IF NOT EXISTS dispatch_review_cycles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cycle_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT NOT NULL,
|
|
driver TEXT NOT NULL,
|
|
candidates_count INTEGER NOT NULL,
|
|
claims_acquired INTEGER NOT NULL,
|
|
swept_count INTEGER NOT NULL,
|
|
processed_count INTEGER NOT NULL,
|
|
terminal_state TEXT,
|
|
worker_outcome TEXT,
|
|
session_id TEXT,
|
|
worker_wallclock_seconds REAL,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dispatch_review_cycles_started_at
|
|
ON dispatch_review_cycles(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_dispatch_review_cycles_terminal
|
|
ON dispatch_review_cycles(terminal_state);
|
|
|
|
CREATE TABLE IF NOT EXISTS dispatch_implementer_cycles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cycle_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT NOT NULL,
|
|
driver TEXT NOT NULL,
|
|
candidates_count INTEGER NOT NULL,
|
|
claims_acquired INTEGER NOT NULL,
|
|
swept_count INTEGER NOT NULL,
|
|
processed_count INTEGER NOT NULL,
|
|
terminal_state TEXT,
|
|
worker_outcome TEXT,
|
|
session_id TEXT,
|
|
worker_wallclock_seconds REAL,
|
|
raw TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dispatch_implementer_cycles_started_at
|
|
ON dispatch_implementer_cycles(started_at);
|
|
CREATE INDEX IF NOT EXISTS idx_dispatch_implementer_cycles_terminal
|
|
ON dispatch_implementer_cycles(terminal_state);
|
|
"""
|
|
|
|
|
|
class ForgejoCache:
|
|
"""SQLite-backed cache with delta sync."""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = Path(db_path)
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
# timeout=30s raises SQLite's busy-timeout so writers wait/retry when
|
|
# a sibling process (e.g. a subprocess opening the same DB) momentarily
|
|
# holds a lock, instead of failing immediately. Python's default is 5s,
|
|
# which isn't enough when a parent renderer spawns count-master-merges.
|
|
self._conn = sqlite3.connect(str(self.db_path), timeout=30.0)
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
# Belt-and-suspenders: the PRAGMA is redundant with timeout=30.0 above,
|
|
# but explicit documents intent and is useful if someone opens the
|
|
# connection differently in the future.
|
|
self._conn.execute("PRAGMA busy_timeout = 30000")
|
|
self._conn.executescript(SCHEMA_SQL)
|
|
self._migrate()
|
|
|
|
@classmethod
|
|
def open(cls, db_path: Path | None = None) -> "ForgejoCache":
|
|
return cls(db_path or DEFAULT_CACHE_PATH)
|
|
|
|
# ─── Schema management ──────────────────────────────────────────────
|
|
|
|
def _migrate(self) -> None:
|
|
row = self._conn.execute("SELECT MAX(version) AS v FROM schema_version").fetchone()
|
|
current = row["v"] if row and row["v"] is not None else 0
|
|
# The new tables in SCHEMA_SQL are all CREATE TABLE IF NOT EXISTS, so
|
|
# they are added on first ``__init__`` after upgrade. Any future
|
|
# migration that mutates an existing table goes here, gated on
|
|
# ``current < N``.
|
|
if current < 5:
|
|
self._migrate_to_v5_in_flight_rows()
|
|
if current < SCHEMA_VERSION:
|
|
self._conn.execute(
|
|
"INSERT INTO schema_version(version) VALUES (?)", (SCHEMA_VERSION,)
|
|
)
|
|
self._conn.commit()
|
|
|
|
def _migrate_to_v5_in_flight_rows(self) -> None:
|
|
"""Drop the NOT NULL constraint on ``ended_at`` for the
|
|
``dispatch_*_cycles`` tables so the Tier 2 dispatchers can record
|
|
a cycle row at start (``ended_at IS NULL``) and update it on
|
|
completion.
|
|
|
|
Delegates to the shared
|
|
:func:`migrate_dispatch_cycle_table_to_v5` helper at module
|
|
scope, which is also imported by
|
|
:func:`_dispatch_runtime.ensure_cycle_table`. Single source of
|
|
truth for the v5 cycle-table schema.
|
|
|
|
Skips tables that don't exist yet (created lazily by
|
|
``ensure_cycle_table`` on the first dispatcher invocation) and
|
|
tables whose ``ended_at`` column is already nullable. All
|
|
pre-existing rows are preserved verbatim.
|
|
"""
|
|
for table in DISPATCH_CYCLE_TABLES:
|
|
migrate_dispatch_cycle_table_to_v5(self._conn, table)
|
|
|
|
# ─── Tier 1B telemetry helpers ─────────────────────────────────────
|
|
|
|
def record_merge_cycle(self, cycle: dict[str, Any]) -> int:
|
|
"""Insert a ``merge_cycle`` row. Returns the new row id.
|
|
|
|
``cycle`` is a dict matching the table columns (extra keys are
|
|
captured into the ``raw`` JSON blob). Used by the merge driver to
|
|
record one full attempt (single-PR or train-merge).
|
|
"""
|
|
cols = (
|
|
"started_at",
|
|
"ended_at",
|
|
"pr_numbers",
|
|
"train_id",
|
|
"bisect_depth",
|
|
"rebase_seconds",
|
|
"ci_seconds",
|
|
"merge_seconds",
|
|
"total_seconds",
|
|
"terminal_state",
|
|
"action_taken",
|
|
"action_detail",
|
|
"last_released_at",
|
|
"flake_rate",
|
|
)
|
|
# Anything not in cols goes into raw for forensics.
|
|
raw_blob = {k: v for k, v in cycle.items() if k not in cols}
|
|
values = [cycle.get(c) for c in cols]
|
|
# pr_numbers MUST be a JSON-encoded array of ints — coerce here so
|
|
# callers can pass either a list or a string.
|
|
prs = values[cols.index("pr_numbers")]
|
|
if isinstance(prs, (list, tuple)):
|
|
values[cols.index("pr_numbers")] = json.dumps(list(prs))
|
|
elif prs is None:
|
|
values[cols.index("pr_numbers")] = "[]"
|
|
# bisect_depth is NOT NULL DEFAULT 0 in the schema; supply 0 when the
|
|
# caller doesn't pass it so the explicit-NULL doesn't violate the
|
|
# constraint.
|
|
if values[cols.index("bisect_depth")] is None:
|
|
values[cols.index("bisect_depth")] = 0
|
|
cur = self._conn.execute(
|
|
f"INSERT INTO merge_cycle ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)",
|
|
(*values, json.dumps(raw_blob) if raw_blob else None),
|
|
)
|
|
self._conn.commit()
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
return rid
|
|
|
|
def record_ci_gate_event(self, event: dict[str, Any]) -> int:
|
|
"""Insert one ``ci_gate_events`` row. Commits immediately — use
|
|
:meth:`record_ci_gate_events_batch` for high-volume callers (the
|
|
merge driver collects all 6 gate outcomes per PR ramp-up; an
|
|
eager fsync per row is wasteful).
|
|
"""
|
|
return self.record_ci_gate_events_batch([event])[-1]
|
|
|
|
def record_ci_gate_events_batch(self, events: list[dict[str, Any]]) -> list[int]:
|
|
"""Insert N ``ci_gate_events`` rows in a single transaction (one
|
|
fsync rather than N). Returns the list of new row ids in input
|
|
order."""
|
|
if not events:
|
|
return []
|
|
cols = (
|
|
"observed_at",
|
|
"pr_number",
|
|
"head_sha",
|
|
"gate",
|
|
"outcome",
|
|
"duration_s",
|
|
"cycle_id",
|
|
)
|
|
rows: list[tuple] = []
|
|
for event in events:
|
|
raw_blob = {k: v for k, v in event.items() if k not in cols}
|
|
rows.append(
|
|
(
|
|
*[event.get(c) for c in cols],
|
|
json.dumps(raw_blob) if raw_blob else None,
|
|
)
|
|
)
|
|
sql = (
|
|
f"INSERT INTO ci_gate_events ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)"
|
|
)
|
|
ids: list[int] = []
|
|
# executemany commits implicitly only inside a ``with self._conn``
|
|
# block; we want a single explicit commit at the end so partial
|
|
# batches don't half-write on error.
|
|
try:
|
|
for row in rows:
|
|
cur = self._conn.execute(sql, row)
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
ids.append(rid)
|
|
self._conn.commit()
|
|
except Exception:
|
|
self._conn.rollback()
|
|
raise
|
|
return ids
|
|
|
|
def record_llm_activity(self, activity: dict[str, Any]) -> int:
|
|
"""Insert one ``llm_activity`` row.
|
|
|
|
Used by both the cost-tracking dashboards (1B) and the agent
|
|
activity stream (Phase 2 deferred item) downstream of this. Callers
|
|
emit one row per LLM invocation.
|
|
"""
|
|
cols = (
|
|
"started_at",
|
|
"ended_at",
|
|
"agent",
|
|
"pr_number",
|
|
"session_tag",
|
|
"model",
|
|
"tokens_in",
|
|
"tokens_out",
|
|
"cached_tokens",
|
|
"decision",
|
|
"outcome",
|
|
"cycle_id",
|
|
)
|
|
raw_blob = {k: v for k, v in activity.items() if k not in cols}
|
|
cur = self._conn.execute(
|
|
f"INSERT INTO llm_activity ({','.join(cols)}, raw) "
|
|
f"VALUES ({','.join('?' for _ in cols)}, ?)",
|
|
(
|
|
*[activity.get(c) for c in cols],
|
|
json.dumps(raw_blob) if raw_blob else None,
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
rid = cur.lastrowid
|
|
assert rid is not None
|
|
return rid
|
|
|
|
def merge_cycles_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
"""Return ``merge_cycle`` rows whose ``started_at`` is within
|
|
[start, end). Used by the pr-velocity canvas renderer."""
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM merge_cycle "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def ci_gate_events_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM ci_gate_events "
|
|
"WHERE observed_at >= ? AND observed_at < ? "
|
|
"ORDER BY observed_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def llm_activity_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM llm_activity "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
# ─── conflict_drive_cycles helpers ─────────────────────────────────
|
|
|
|
def record_conflict_drive_cycle(self, row: dict[str, Any]) -> None:
|
|
"""Insert one ``conflict_drive_cycles`` row.
|
|
|
|
``row`` must carry every NOT NULL column. ``details_json`` is
|
|
encoded if a dict is passed; if the caller already JSON-encoded
|
|
it, the string is stored as-is. ``outcome`` and ``failure_kind``
|
|
live both as top-level indexed columns and inside ``details_json``;
|
|
callers MUST keep them in sync (a single
|
|
``record_telemetry`` helper in ``conflict_drive.py`` is the
|
|
intended source of truth — see plan § 9).
|
|
"""
|
|
details = row.get("details_json")
|
|
if isinstance(details, (dict, list)):
|
|
details_str = json.dumps(details)
|
|
else:
|
|
details_str = details if details is not None else "{}"
|
|
cols = (
|
|
"cycle_id",
|
|
"pr_number",
|
|
"started_at",
|
|
"ended_at",
|
|
"outcome",
|
|
"failure_kind",
|
|
"candidates_count",
|
|
"resolved_count",
|
|
"escalated_count",
|
|
"timeout_count",
|
|
"push_rejected_count",
|
|
"details_json",
|
|
)
|
|
values = list(row.get(c) for c in cols)
|
|
values[cols.index("details_json")] = details_str
|
|
# Use INSERT OR REPLACE so a same-cycle re-record (e.g. a retry
|
|
# mid-cycle) doesn't trip the PRIMARY KEY (cycle_id, pr_number).
|
|
# Cycle ids are intended to be unique-per-cycle; replace is the
|
|
# safe default if a caller emits twice by accident.
|
|
self._conn.execute(
|
|
f"INSERT OR REPLACE INTO conflict_drive_cycles "
|
|
f"({','.join(cols)}) VALUES ({','.join('?' for _ in cols)})",
|
|
values,
|
|
)
|
|
self._conn.commit()
|
|
|
|
def mark_conflict_drive_cycle_escalated(
|
|
self, cycle_id: str, pr_number: int
|
|
) -> int:
|
|
"""Set ``escalated_count = 1`` on the row keyed by
|
|
``(cycle_id, pr_number)``.
|
|
|
|
Telemetry semantics (plan § 9): ``escalated_count`` is a
|
|
cycle-level event — it fires exactly once across the rolling
|
|
24-hour retry budget when ``conflict_drive.run_one_cycle``
|
|
decides the budget is exhausted and applies
|
|
``auto/needs-implementer``. ``record_conflict_drive_cycle``
|
|
always writes ``escalated_count = 0`` because at row-write
|
|
time the budget verdict has not yet been computed (the
|
|
verdict needs the row that's being written). This helper is
|
|
the second-phase update; calling it twice for the same row
|
|
is idempotent.
|
|
|
|
Returns the number of rows updated. Zero rows means the
|
|
telemetry write earlier in the cycle either failed or used a
|
|
different ``cycle_id`` — the caller may use this to decide
|
|
whether to log a diagnostic. The caller is responsible for
|
|
any logging; this helper stays silent so the cache module
|
|
does not depend on a specific logger.
|
|
"""
|
|
cur = self._conn.execute(
|
|
"UPDATE conflict_drive_cycles SET escalated_count = 1 "
|
|
"WHERE cycle_id = ? AND pr_number = ?",
|
|
(cycle_id, pr_number),
|
|
)
|
|
self._conn.commit()
|
|
return cur.rowcount or 0
|
|
|
|
def count_conflict_drive_definite_failures_24h(
|
|
self, pr_number: int, *, now: datetime | None = None
|
|
) -> int:
|
|
"""Return the number of ``failure_kind='definite'`` rows for
|
|
``pr_number`` whose ``started_at`` is within the trailing 24 h
|
|
window. This is the retry-budget query at plan § 5.1 — used by
|
|
``conflict_drive.resolve_one`` to decide whether the next failure
|
|
triggers escalation to ``auto/needs-implementer``.
|
|
|
|
Uses the ``idx_cdc_pr_number_started`` index for an O(log N) lookup.
|
|
``now`` is injectable so unit tests can pin the window without
|
|
time-traveling the wall clock.
|
|
"""
|
|
cutoff = (now or datetime.now(timezone.utc)) - timedelta(hours=24)
|
|
row = self._conn.execute(
|
|
"SELECT COUNT(*) AS n FROM conflict_drive_cycles "
|
|
"WHERE pr_number = ? AND failure_kind = 'definite' "
|
|
"AND started_at > ?",
|
|
(pr_number, cutoff.isoformat()),
|
|
).fetchone()
|
|
return int(row["n"]) if row else 0
|
|
|
|
def conflict_drive_cycles_in_window(
|
|
self, start: datetime, end: datetime
|
|
) -> list[dict[str, Any]]:
|
|
"""Return ``conflict_drive_cycles`` rows whose ``started_at`` is
|
|
within [start, end). Used by ``tools/render-pr-velocity.py`` for
|
|
the "Conflict-resolution activity" section.
|
|
|
|
Uses the ``idx_cdc_started_at`` index for an O(log N) seek.
|
|
"""
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM conflict_drive_cycles "
|
|
"WHERE started_at >= ? AND started_at < ? "
|
|
"ORDER BY started_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def last_conflict_drive_cycle_for_pr(
|
|
self, pr_number: int
|
|
) -> dict[str, Any] | None:
|
|
"""Return the most recent ``conflict_drive_cycles`` row for
|
|
``pr_number`` (highest ``started_at``), or ``None`` if no row
|
|
exists.
|
|
|
|
Used by ``tools/conflict_drive.py`` for the per-cycle cooldown
|
|
check. Uses the ``idx_cdc_pr_number_started`` index, so the
|
|
lookup is O(log N) — important because this fires once per
|
|
candidate per cycle.
|
|
"""
|
|
row = self._conn.execute(
|
|
"SELECT * FROM conflict_drive_cycles "
|
|
"WHERE pr_number = ? "
|
|
"ORDER BY started_at DESC LIMIT 1",
|
|
(pr_number,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def last_released_at_for_pr(self, pr_number: int) -> str | None:
|
|
"""Most recent ``last_released_at`` from merge_cycle for one PR.
|
|
|
|
Used by the merge driver's candidate filter to enforce the
|
|
cooldown window without depending on labels (S4 simplification:
|
|
labels persist for visibility, but eligibility is computed from
|
|
telemetry).
|
|
"""
|
|
# Pull cycles that mention this PR and sort by ended_at desc.
|
|
# SQLite has no native JSON containment, so we use a LIKE filter
|
|
# and then verify in Python (cheap; merge_cycle is small).
|
|
rows = self._conn.execute(
|
|
"SELECT pr_numbers, last_released_at FROM merge_cycle "
|
|
"WHERE last_released_at IS NOT NULL "
|
|
" AND pr_numbers LIKE ? "
|
|
"ORDER BY last_released_at DESC LIMIT 50",
|
|
(f"%{pr_number}%",),
|
|
).fetchall()
|
|
for r in rows:
|
|
try:
|
|
prs = json.loads(r["pr_numbers"])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if pr_number in prs:
|
|
return r["last_released_at"]
|
|
return None
|
|
|
|
# ─── sync_meta helpers ──────────────────────────────────────────────
|
|
|
|
def _meta_get(self, key: str) -> str | None:
|
|
row = self._conn.execute("SELECT value FROM sync_meta WHERE key = ?", (key,)).fetchone()
|
|
return row["value"] if row else None
|
|
|
|
def _meta_set(self, key: str, value: str) -> None:
|
|
self._conn.execute(
|
|
"INSERT INTO sync_meta(key, value) VALUES(?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(key, value),
|
|
)
|
|
|
|
# ─── Locking ────────────────────────────────────────────────────────
|
|
|
|
def _lock(self):
|
|
"""fcntl.flock on ``<db_path>.lock`` for exclusive sync."""
|
|
lock_path = Path(str(self.db_path) + ".lock")
|
|
lock_path.touch()
|
|
fd = os.open(str(lock_path), os.O_RDWR)
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
return fd
|
|
|
|
@staticmethod
|
|
def _unlock(fd: int) -> None:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
# ─── Commits sync ───────────────────────────────────────────────────
|
|
|
|
def _upsert_commit(self, c: dict) -> bool:
|
|
"""Insert a commit if new. Returns True if a new row was inserted."""
|
|
sha = c.get("sha")
|
|
if not sha:
|
|
return False
|
|
row = self._conn.execute(
|
|
"INSERT OR IGNORE INTO commits "
|
|
"(sha, committer_date, author_name, committer_name, message, parents, raw) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
sha,
|
|
(c.get("commit", {}).get("committer", {}) or {}).get("date"),
|
|
(c.get("commit", {}).get("author", {}) or {}).get("name"),
|
|
(c.get("commit", {}).get("committer", {}) or {}).get("name"),
|
|
(c.get("commit", {}) or {}).get("message"),
|
|
json.dumps([p.get("sha") for p in (c.get("parents") or [])]),
|
|
json.dumps(c),
|
|
),
|
|
)
|
|
return row.rowcount > 0
|
|
|
|
def _sync_commits(self, token: str, full: bool, progress: bool) -> dict:
|
|
"""Pull master commits forward from HEAD until we reach cached content.
|
|
|
|
When ``full=True`` the cache is truncated first (use after a force-push
|
|
or to rebuild from scratch).
|
|
"""
|
|
if full:
|
|
self._conn.execute("DELETE FROM commits")
|
|
self._conn.commit()
|
|
|
|
prev_head = self._meta_get("master_head_sha")
|
|
known_shas = {
|
|
row["sha"] for row in self._conn.execute("SELECT sha FROM commits")
|
|
}
|
|
new_count = 0
|
|
consecutive_all_cached = 0
|
|
first_sha: str | None = None
|
|
saw_prev_head = False
|
|
|
|
for page in range(1, SYNC_PAGE_HARD_LIMIT + 1):
|
|
commits = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits"
|
|
f"?sha=master&limit=50&page={page}",
|
|
token,
|
|
)
|
|
if not commits:
|
|
break
|
|
if first_sha is None and commits:
|
|
first_sha = commits[0].get("sha")
|
|
page_new = 0
|
|
for c in commits:
|
|
sha = c.get("sha")
|
|
if sha == prev_head:
|
|
saw_prev_head = True
|
|
if sha in known_shas:
|
|
continue
|
|
if self._upsert_commit(c):
|
|
known_shas.add(sha)
|
|
new_count += 1
|
|
page_new += 1
|
|
if progress and (page == 1 or page % 10 == 0):
|
|
print(
|
|
f"# commits: page {page}, +{page_new} new (running total {new_count})",
|
|
file=sys.stderr,
|
|
)
|
|
# Stop when we've hit a page that's entirely old content (meaning
|
|
# we've caught up to the cache's existing HEAD).
|
|
if page_new == 0:
|
|
consecutive_all_cached += 1
|
|
if consecutive_all_cached >= SYNC_EMPTY_PAGE_TOLERANCE:
|
|
break
|
|
else:
|
|
consecutive_all_cached = 0
|
|
if len(commits) < 50:
|
|
break
|
|
|
|
self._conn.commit()
|
|
if first_sha:
|
|
self._meta_set("master_head_sha", first_sha)
|
|
self._meta_set("last_commit_sync_at", datetime.now(timezone.utc).isoformat())
|
|
|
|
return {
|
|
"new_commits": new_count,
|
|
"master_head_sha": first_sha,
|
|
"force_push_suspected": (
|
|
prev_head is not None and not saw_prev_head and new_count > 50
|
|
),
|
|
}
|
|
|
|
# ─── PR sync ────────────────────────────────────────────────────────
|
|
|
|
def _upsert_pr(self, pr: dict, has_detail: bool | None = None) -> bool:
|
|
"""Insert or update a PR. Returns True if row was new or updated_at advanced."""
|
|
num = pr.get("number")
|
|
if num is None:
|
|
return False
|
|
existing = self._conn.execute(
|
|
"SELECT updated_at, has_detail FROM pulls WHERE number = ?", (num,),
|
|
).fetchone()
|
|
new_updated = pr.get("updated_at")
|
|
if existing is not None and existing["updated_at"] == new_updated:
|
|
# No change; but we may have just pulled detail — update has_detail if so.
|
|
if has_detail and not existing["has_detail"]:
|
|
self._conn.execute(
|
|
"UPDATE pulls SET has_detail = 1, merged_by_login = ?, "
|
|
"additions = ?, deletions = ?, changed_files = ?, raw = ? "
|
|
"WHERE number = ?",
|
|
(
|
|
(pr.get("merged_by") or {}).get("login"),
|
|
pr.get("additions"),
|
|
pr.get("deletions"),
|
|
pr.get("changed_files"),
|
|
json.dumps(pr),
|
|
num,
|
|
),
|
|
)
|
|
return True
|
|
return False
|
|
|
|
self._conn.execute(
|
|
"INSERT INTO pulls "
|
|
"(number, state, merged, created_at, updated_at, closed_at, merged_at, "
|
|
" base_ref, head_sha, merge_commit_sha, user_login, merged_by_login, "
|
|
" additions, deletions, changed_files, labels, has_detail, raw) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(number) DO UPDATE SET "
|
|
" state = excluded.state, merged = excluded.merged, "
|
|
" created_at = excluded.created_at, updated_at = excluded.updated_at, "
|
|
" closed_at = excluded.closed_at, merged_at = excluded.merged_at, "
|
|
" base_ref = excluded.base_ref, head_sha = excluded.head_sha, "
|
|
" merge_commit_sha = excluded.merge_commit_sha, "
|
|
" user_login = excluded.user_login, "
|
|
" merged_by_login = COALESCE(excluded.merged_by_login, pulls.merged_by_login), "
|
|
" additions = COALESCE(excluded.additions, pulls.additions), "
|
|
" deletions = COALESCE(excluded.deletions, pulls.deletions), "
|
|
" changed_files = COALESCE(excluded.changed_files, pulls.changed_files), "
|
|
" labels = excluded.labels, "
|
|
" has_detail = MAX(excluded.has_detail, pulls.has_detail), "
|
|
" raw = excluded.raw",
|
|
(
|
|
num,
|
|
pr.get("state"),
|
|
1 if pr.get("merged") else 0,
|
|
pr.get("created_at"),
|
|
pr.get("updated_at"),
|
|
pr.get("closed_at"),
|
|
pr.get("merged_at"),
|
|
(pr.get("base") or {}).get("ref"),
|
|
(pr.get("head") or {}).get("sha"),
|
|
pr.get("merge_commit_sha"),
|
|
(pr.get("user") or {}).get("login"),
|
|
(pr.get("merged_by") or {}).get("login"),
|
|
pr.get("additions"),
|
|
pr.get("deletions"),
|
|
pr.get("changed_files"),
|
|
json.dumps([lbl.get("name") for lbl in (pr.get("labels") or [])]),
|
|
1 if has_detail else 0,
|
|
json.dumps(pr),
|
|
),
|
|
)
|
|
return True
|
|
|
|
def _sync_pulls(self, token: str, full: bool, progress: bool) -> dict:
|
|
"""Sync all PRs by walking ``pulls?state=all&sort=recentupdate``.
|
|
|
|
Stops when we see a page whose most-recent updated_at is older than
|
|
our last-sync cursor (everything older is already cached and stable).
|
|
"""
|
|
if full:
|
|
self._conn.execute("DELETE FROM pulls")
|
|
self._conn.commit()
|
|
|
|
last_sync = self._meta_get("last_pull_sync_updated_at")
|
|
if full or last_sync is None:
|
|
last_sync_dt = None
|
|
else:
|
|
last_sync_dt = last_sync # string comparison is safe on ISO 8601
|
|
|
|
upserts = 0
|
|
max_updated: str | None = None
|
|
consecutive_stale_pages = 0
|
|
for page in range(1, SYNC_PAGE_HARD_LIMIT + 1):
|
|
prs = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls"
|
|
f"?state=all&sort=recentupdate&limit=50&page={page}",
|
|
token,
|
|
)
|
|
if not prs:
|
|
break
|
|
page_new_or_updated = 0
|
|
page_min_updated: str | None = None
|
|
for pr in prs:
|
|
upd = pr.get("updated_at")
|
|
if upd is not None:
|
|
if page_min_updated is None or upd < page_min_updated:
|
|
page_min_updated = upd
|
|
if max_updated is None or upd > max_updated:
|
|
max_updated = upd
|
|
if last_sync_dt is not None and upd is not None and upd <= last_sync_dt:
|
|
continue
|
|
if self._upsert_pr(pr):
|
|
upserts += 1
|
|
page_new_or_updated += 1
|
|
|
|
if progress and (page == 1 or page % 20 == 0):
|
|
print(
|
|
f"# pulls: page {page}, +{page_new_or_updated} upserts "
|
|
f"(running total {upserts})",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Once the oldest updated_at on this page is older than our last
|
|
# sync cursor, any subsequent page is entirely older still.
|
|
if (
|
|
last_sync_dt is not None
|
|
and page_min_updated is not None
|
|
and page_min_updated <= last_sync_dt
|
|
):
|
|
break
|
|
|
|
if page_new_or_updated == 0:
|
|
consecutive_stale_pages += 1
|
|
if consecutive_stale_pages >= SYNC_EMPTY_PAGE_TOLERANCE:
|
|
break
|
|
else:
|
|
consecutive_stale_pages = 0
|
|
|
|
if len(prs) < 50:
|
|
break
|
|
|
|
self._conn.commit()
|
|
if max_updated:
|
|
self._meta_set("last_pull_sync_updated_at", max_updated)
|
|
self._meta_set("last_pull_sync_at", datetime.now(timezone.utc).isoformat())
|
|
return {"pulls_upserted": upserts, "max_updated_at": max_updated}
|
|
|
|
# ─── PR enrichment (lazy, on read) ──────────────────────────────────
|
|
|
|
def enrich_pr(self, number: int, token: str) -> dict:
|
|
"""Return the full PR dict, fetching detail on demand.
|
|
|
|
The list endpoint omits ``merged_by``, ``additions``, ``deletions``,
|
|
and ``changed_files``. We fetch the detail once per PR and persist it.
|
|
"""
|
|
row = self._conn.execute(
|
|
"SELECT raw, has_detail FROM pulls WHERE number = ?", (number,),
|
|
).fetchone()
|
|
if row is None:
|
|
# Not in cache at all — fetch and store.
|
|
pr = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{number}", token,
|
|
)
|
|
self._upsert_pr(pr, has_detail=True)
|
|
self._conn.commit()
|
|
return pr
|
|
if row["has_detail"]:
|
|
return json.loads(row["raw"])
|
|
# Need to fetch detail.
|
|
pr = api_get(
|
|
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls/{number}", token,
|
|
)
|
|
self._upsert_pr(pr, has_detail=True)
|
|
self._conn.commit()
|
|
return pr
|
|
|
|
def backfill_merged_by(self, token: str, progress: bool = True) -> int:
|
|
"""One-shot: fetch detail for every in-cache closed+merged PR that
|
|
lacks ``merged_by_login``. Called optionally after a fresh sync to
|
|
warm the merged-by cache. Returns number of PRs enriched."""
|
|
rows = self._conn.execute(
|
|
"SELECT number FROM pulls "
|
|
"WHERE state = 'closed' AND merged = 1 AND merged_by_login IS NULL "
|
|
"AND has_detail = 0"
|
|
).fetchall()
|
|
n = 0
|
|
for i, row in enumerate(rows):
|
|
self.enrich_pr(row["number"], token)
|
|
n += 1
|
|
if progress and (i + 1) % 50 == 0:
|
|
print(f"# merged_by backfill: {i+1}/{len(rows)}", file=sys.stderr)
|
|
return n
|
|
|
|
# ─── Reachability (compare endpoint) ────────────────────────────────
|
|
|
|
def is_sha_reachable_from_master(
|
|
self, sha: str, token: str, *, master_head_sha: str | None = None,
|
|
) -> bool:
|
|
"""Cached reachability check.
|
|
|
|
Cache key is ``(merge_sha, master_head_sha)``; a positive result is
|
|
permanent (a commit that was ever an ancestor of master stays so,
|
|
absent a force-push), but negative results are re-checked if the
|
|
master HEAD has advanced.
|
|
"""
|
|
if not sha:
|
|
return False
|
|
row = self._conn.execute(
|
|
"SELECT reachable, master_head_sha FROM reachability WHERE merge_sha = ?",
|
|
(sha,),
|
|
).fetchone()
|
|
if row is not None:
|
|
if row["reachable"]:
|
|
return True
|
|
# Negative result: only trust it if master HEAD hasn't changed.
|
|
cur_head = master_head_sha or self._meta_get("master_head_sha")
|
|
if cur_head and row["master_head_sha"] == cur_head:
|
|
return False
|
|
# Miss or stale negative — do the API call.
|
|
url = f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/compare/master...{sha}"
|
|
try:
|
|
data = api_get(url, token)
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
reachable = False
|
|
else:
|
|
raise
|
|
else:
|
|
total = data.get("total_commits")
|
|
if total is None:
|
|
total = len(data.get("commits") or [])
|
|
reachable = total == 0
|
|
cur_head = master_head_sha or self._meta_get("master_head_sha")
|
|
self._conn.execute(
|
|
"INSERT INTO reachability(merge_sha, reachable, checked_at, master_head_sha) "
|
|
"VALUES(?, ?, ?, ?) "
|
|
"ON CONFLICT(merge_sha) DO UPDATE SET "
|
|
" reachable = excluded.reachable, "
|
|
" checked_at = excluded.checked_at, "
|
|
" master_head_sha = excluded.master_head_sha",
|
|
(sha, 1 if reachable else 0, datetime.now(timezone.utc).isoformat(), cur_head),
|
|
)
|
|
self._conn.commit()
|
|
return reachable
|
|
|
|
# ─── Top-level sync ─────────────────────────────────────────────────
|
|
|
|
def sync(
|
|
self,
|
|
token: str,
|
|
*,
|
|
full: bool = False,
|
|
backfill_details: bool = False,
|
|
progress: bool = True,
|
|
) -> dict:
|
|
"""Delta-sync the cache. Returns a summary dict."""
|
|
lock_fd = self._lock()
|
|
try:
|
|
if progress:
|
|
print("# syncing commits ...", file=sys.stderr)
|
|
commits_summary = self._sync_commits(token, full, progress)
|
|
if commits_summary.get("force_push_suspected"):
|
|
print(
|
|
"# WARN: previous master HEAD not seen in recent commits — "
|
|
"possible force-push. Re-run with --full to rebuild from scratch.",
|
|
file=sys.stderr,
|
|
)
|
|
if progress:
|
|
print("# syncing pulls ...", file=sys.stderr)
|
|
pulls_summary = self._sync_pulls(token, full, progress)
|
|
enriched = 0
|
|
if backfill_details:
|
|
if progress:
|
|
print("# back-filling merged_by for merged PRs ...", file=sys.stderr)
|
|
enriched = self.backfill_merged_by(token, progress=progress)
|
|
return {
|
|
"commits": commits_summary,
|
|
"pulls": pulls_summary,
|
|
"enriched_prs": enriched,
|
|
}
|
|
finally:
|
|
self._unlock(lock_fd)
|
|
|
|
# ─── Query API (all return raw API-shaped dicts) ────────────────────
|
|
|
|
def commits_on_master(
|
|
self, start: datetime, end: datetime,
|
|
) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM commits "
|
|
"WHERE committer_date >= ? AND committer_date < ? "
|
|
"ORDER BY committer_date DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_closed_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE state = 'closed' AND closed_at IS NOT NULL "
|
|
" AND closed_at >= ? AND closed_at < ? "
|
|
"ORDER BY closed_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_merged_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE merged = 1 AND merged_at IS NOT NULL "
|
|
" AND merged_at >= ? AND merged_at < ? "
|
|
"ORDER BY merged_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def pulls_opened_in_window(self, start: datetime, end: datetime) -> list[dict]:
|
|
rows = self._conn.execute(
|
|
"SELECT raw FROM pulls "
|
|
"WHERE created_at >= ? AND created_at < ? "
|
|
"ORDER BY created_at DESC",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
return [json.loads(r["raw"]) for r in rows]
|
|
|
|
def sha_to_pr_map(self, start: datetime, end: datetime) -> dict[str, dict]:
|
|
"""SHA -> PR dict for PRs merged in window (indexes both head.sha
|
|
and merge_commit_sha), for count-master-merges Phase 1 cross-reference.
|
|
"""
|
|
rows = self._conn.execute(
|
|
"SELECT raw, head_sha, merge_commit_sha FROM pulls "
|
|
"WHERE merged = 1 AND merged_at IS NOT NULL "
|
|
" AND merged_at >= ? AND merged_at < ?",
|
|
(start.isoformat(), end.isoformat()),
|
|
).fetchall()
|
|
out: dict[str, dict] = {}
|
|
for r in rows:
|
|
pr = json.loads(r["raw"])
|
|
if r["head_sha"]:
|
|
out[r["head_sha"]] = pr
|
|
if r["merge_commit_sha"]:
|
|
out[r["merge_commit_sha"]] = pr
|
|
return out
|
|
|
|
# ─── Introspection helpers ──────────────────────────────────────────
|
|
|
|
def stats(self) -> dict:
|
|
c = self._conn
|
|
row = c.execute("SELECT COUNT(*) AS n, MIN(committer_date) AS oldest, "
|
|
"MAX(committer_date) AS newest FROM commits").fetchone()
|
|
pull_row = c.execute(
|
|
"SELECT COUNT(*) AS n, MIN(created_at) AS oldest_created, "
|
|
"MAX(updated_at) AS latest_update FROM pulls"
|
|
).fetchone()
|
|
reach_row = c.execute("SELECT COUNT(*) AS n FROM reachability").fetchone()
|
|
return {
|
|
"cache_path": str(self.db_path),
|
|
"cache_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
|
|
"commits": {
|
|
"count": row["n"],
|
|
"oldest": row["oldest"],
|
|
"newest": row["newest"],
|
|
},
|
|
"pulls": {
|
|
"count": pull_row["n"],
|
|
"oldest_created": pull_row["oldest_created"],
|
|
"latest_update": pull_row["latest_update"],
|
|
},
|
|
"reachability_cached": reach_row["n"],
|
|
"last_commit_sync_at": self._meta_get("last_commit_sync_at"),
|
|
"last_pull_sync_at": self._meta_get("last_pull_sync_at"),
|
|
"master_head_sha": self._meta_get("master_head_sha"),
|
|
}
|
|
|
|
def close(self) -> None:
|
|
self._conn.close()
|