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>
252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
"""Master-side reaper: reset stale-heartbeat workflow_attempts to
|
|
pending so another worker can re-acquire.
|
|
|
|
Per plan v9 simplified: TTL-only reaper. No JOIN-with-workflows
|
|
superseded check (deferred — orphan re-attempts no-op cheaply against
|
|
terminal workflows when the next worker tries to advance them).
|
|
|
|
Runs on the master's tick loop every ``CONTROLLER_REAPER_INTERVAL_S``
|
|
(default 60s). The reaper:
|
|
- Selects ``workflow_attempts WHERE status='in_progress' AND
|
|
lock_heartbeat_at < NOW() - lock_ttl_seconds``
|
|
- For each: status → 'pending'; clear locked_by_instance / locked_at /
|
|
lock_heartbeat_at; insert a ``controller_events`` row with
|
|
reason='lock-ttl-expired'.
|
|
|
|
The reset BUMPS ``pickup_count`` (each reap = one failed-pickup).
|
|
The master's separate pickup-guard catches
|
|
``pickup_count >= MAX_PICKUPS`` and STUCKs the workflow. Bumping on
|
|
reap (rather than on dequeue) means a healthy worker that simply
|
|
acquires an attempt doesn't burn a pickup — only crashes /
|
|
stale-heartbeat resets do.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Engine
|
|
|
|
from .db.session import session_scope
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ReaperReport:
|
|
"""Per-sweep summary; emitted to structured logs + Prometheus."""
|
|
|
|
rows_reaped: int = 0
|
|
reaped_attempts: list[tuple[int, str | None]] = field(default_factory=list)
|
|
# Each entry: (attempt_id, locked_by_instance) — useful for
|
|
# operator forensics ("which machine's worker died?")
|
|
|
|
|
|
def reap_stale_attempts(engine: Engine) -> ReaperReport:
|
|
"""Reset stale-heartbeat in_progress attempts to pending.
|
|
|
|
The TTL is per-row (``lock_ttl_seconds``) — different roles can
|
|
have different TTLs (estimator ~180s; reviewer ~720s; tier-2
|
|
implementer ~2160s). The WHERE clause uses the per-row column.
|
|
"""
|
|
report = ReaperReport()
|
|
now = datetime.now(timezone.utc)
|
|
# Two-step approach for portability:
|
|
# 1. SELECT the candidates (so we can log them).
|
|
# 2. UPDATE them.
|
|
# We could do this in one statement on Postgres (RETURNING) and
|
|
# SQLite (RETURNING is supported in 3.35+). Splitting for
|
|
# observability — the reaper isn't a hot path.
|
|
with session_scope(engine) as session:
|
|
dialect = session.bind.dialect.name if session.bind else "sqlite"
|
|
if dialect == "postgresql":
|
|
select_sql = text(
|
|
"SELECT attempt_id, locked_by_instance, lock_heartbeat_at "
|
|
"FROM workflow_attempts "
|
|
"WHERE status = 'in_progress' "
|
|
" AND lock_heartbeat_at IS NOT NULL "
|
|
" AND lock_heartbeat_at + (lock_ttl_seconds || ' seconds')::interval < :now"
|
|
)
|
|
else:
|
|
# SQLite: TIMESTAMP arithmetic via julianday().
|
|
select_sql = text(
|
|
"SELECT attempt_id, locked_by_instance, lock_heartbeat_at "
|
|
"FROM workflow_attempts "
|
|
"WHERE status = 'in_progress' "
|
|
" AND lock_heartbeat_at IS NOT NULL "
|
|
" AND (julianday(:now) - julianday(lock_heartbeat_at)) "
|
|
" * 86400 > lock_ttl_seconds"
|
|
)
|
|
rows = session.execute(select_sql, {"now": now}).all()
|
|
if not rows:
|
|
return report
|
|
|
|
# 2. UPDATE — use IN-clause with the candidate ids.
|
|
#
|
|
# R-4 fix (2026-05-19): re-check freshness in the WHERE clause
|
|
# so a worker's healthy heartbeat between SELECT and UPDATE
|
|
# protects the row. Pre-fix the UPDATE was unconditional on
|
|
# attempt_id; a heartbeat that succeeded in the gap would be
|
|
# silently overwritten, the worker's subsequent _write_outcome
|
|
# (filtered on locked_by_instance) would rowcount=0, and the
|
|
# worker's output would be lost without operator signal.
|
|
ids = [r.attempt_id for r in rows]
|
|
# SQLAlchemy's expanding bindparam handles IN over a list.
|
|
from sqlalchemy import bindparam
|
|
|
|
update_sql = text(
|
|
"UPDATE workflow_attempts SET "
|
|
" status = 'pending', "
|
|
" locked_by_instance = NULL, "
|
|
" locked_at = NULL, "
|
|
" lock_heartbeat_at = NULL, "
|
|
" pickup_count = pickup_count + 1 "
|
|
"WHERE attempt_id IN :ids "
|
|
" AND status = 'in_progress' "
|
|
" AND lock_heartbeat_at IS NOT NULL "
|
|
" AND (julianday(:now) - julianday(lock_heartbeat_at)) "
|
|
" * 86400 > lock_ttl_seconds"
|
|
).bindparams(bindparam("ids", expanding=True))
|
|
result = session.execute(update_sql, {"ids": ids, "now": now})
|
|
report.rows_reaped = result.rowcount
|
|
|
|
# 3. Log + record controller_events.
|
|
events_sql = text(
|
|
"INSERT INTO controller_events "
|
|
"(workflow_id, ts, event_type, payload, forgejo_write_pending, replay_attempts) "
|
|
"VALUES ("
|
|
" (SELECT workflow_id FROM workflow_attempts WHERE attempt_id = :attempt_id), "
|
|
" :ts, 'lock-ttl-expired', :payload, 0, 0"
|
|
")"
|
|
)
|
|
for r in rows:
|
|
report.reaped_attempts.append((r.attempt_id, r.locked_by_instance))
|
|
session.execute(
|
|
events_sql,
|
|
{
|
|
"attempt_id": r.attempt_id,
|
|
"ts": now,
|
|
"payload": _payload_for_event(
|
|
r.attempt_id,
|
|
r.locked_by_instance,
|
|
r.lock_heartbeat_at,
|
|
now,
|
|
),
|
|
},
|
|
)
|
|
|
|
if report.rows_reaped:
|
|
logger.warning(
|
|
"reaper reset %d stale in_progress attempt(s): %s",
|
|
report.rows_reaped,
|
|
[(aid, inst) for (aid, inst) in report.reaped_attempts],
|
|
)
|
|
return report
|
|
|
|
|
|
def _payload_for_event(
|
|
attempt_id: int,
|
|
locked_by_instance: str | None,
|
|
heartbeat_at: datetime | None,
|
|
now: datetime,
|
|
) -> str:
|
|
"""Build a JSON string payload for the controller_events row."""
|
|
import json
|
|
|
|
age_s: float | None = None
|
|
if heartbeat_at is not None:
|
|
try:
|
|
# heartbeat_at may be naive depending on the DB; normalise.
|
|
if heartbeat_at.tzinfo is None:
|
|
from datetime import timezone as _tz
|
|
|
|
hb = heartbeat_at.replace(tzinfo=_tz.utc)
|
|
else:
|
|
hb = heartbeat_at
|
|
age_s = (now - hb).total_seconds()
|
|
except Exception:
|
|
age_s = None
|
|
return json.dumps(
|
|
{
|
|
"reaped_attempt_id": attempt_id,
|
|
"previously_locked_by": locked_by_instance,
|
|
"heartbeat_age_s": age_s,
|
|
}
|
|
)
|
|
|
|
|
|
# ─── Phase 1 grooming: audit-retention sweep ─────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class GroomingRetentionReport:
|
|
"""Per-sweep summary of grooming_decisions retention."""
|
|
|
|
rows_trimmed: int = 0
|
|
retention_days: int = 0
|
|
|
|
|
|
def reap_grooming_decisions(
|
|
engine: Engine,
|
|
*,
|
|
retention_days: int | None = None,
|
|
) -> GroomingRetentionReport:
|
|
"""Phase 1 grooming plan: trim ``verdict='proceed'`` audit rows
|
|
older than ``CONTROLLER_GROOMING_PROCEED_RETENTION_DAYS`` (default
|
|
30). ``verdict in {'defer', 'close'}`` rows are KEPT INDEFINITELY —
|
|
they're the operator-facing audit trail.
|
|
|
|
Run on the same tick cadence as ``reap_stale_attempts``; the
|
|
deletion is small (one DELETE) so the cost-per-tick is negligible
|
|
even at scale.
|
|
"""
|
|
import os
|
|
|
|
if retention_days is None:
|
|
try:
|
|
retention_days = int(
|
|
os.environ.get("CONTROLLER_GROOMING_PROCEED_RETENTION_DAYS", "30")
|
|
)
|
|
except ValueError:
|
|
retention_days = 30
|
|
if retention_days < 0:
|
|
# Negative retention disables the sweep — useful when an
|
|
# operator wants to keep every row during validation.
|
|
return GroomingRetentionReport(rows_trimmed=0, retention_days=retention_days)
|
|
|
|
report = GroomingRetentionReport(rows_trimmed=0, retention_days=retention_days)
|
|
with session_scope(engine) as session:
|
|
dialect = session.bind.dialect.name if session.bind else "sqlite"
|
|
if dialect == "postgresql":
|
|
del_sql = text(
|
|
"DELETE FROM grooming_decisions "
|
|
"WHERE verdict = 'proceed' "
|
|
" AND decided_at < (NOW() - (:days || ' days')::interval)"
|
|
)
|
|
else:
|
|
del_sql = text(
|
|
"DELETE FROM grooming_decisions "
|
|
"WHERE verdict = 'proceed' "
|
|
" AND julianday(decided_at) < julianday('now', '-' || :days || ' days')"
|
|
)
|
|
result = session.execute(del_sql, {"days": retention_days})
|
|
report.rows_trimmed = int(result.rowcount or 0)
|
|
if report.rows_trimmed:
|
|
logger.info(
|
|
"reap_grooming_decisions: trimmed %d proceed rows older than %d days",
|
|
report.rows_trimmed,
|
|
retention_days,
|
|
)
|
|
return report
|
|
|
|
|
|
__all__ = [
|
|
"GroomingRetentionReport",
|
|
"ReaperReport",
|
|
"reap_grooming_decisions",
|
|
"reap_stale_attempts",
|
|
]
|