Files
cleveragents-core/tools/controller/master/_events.py
T
drew a91df787d7 feat(controller): Phase 2 — Gate 2 estimator-abandon
Adds the second of three abandon gates: the estimator (Gate 2) can
mark a work item fundamentally unworkable, transitioning the
workflow ANALYZING -> ABANDONED and triggering a Forgejo close via
Phase 1's decomposed close_act orchestrator. Catches abandon cases
at the cheapest LLM stage, before implementer/reviewer tiers fire.

Substantive:
- EstimatorOutputV1: additive verdict + abandon_reason_category +
  abandon_reason_detail fields (pre-Phase-2 outputs still parse).
  @model_validator enforces abandon-requires-category atomicity at
  parse time — third defense layer beyond MCP setter + outcomes
  mapper
- state_machine: estimator_abandon event + (ANALYZING,
  estimator_abandon) -> ABANDONED. 57 transitions; invariants clean
- mcp/estimator_builder: estimator_set_verdict setter validates
  verdict enum + 9-category whitelist (scope_intractable,
  intent_wrong, security_regression, deprecated_dependency,
  breaks_protected_invariants, out_of_scope, low_value,
  unmaintained_path, policy_violation) + cross-field rules
- outcomes._map_estimator_outcome: dispatch verdict='abandon'
  -> estimator_abandon, with confidence-low downgrade to
  estimator_done (honors the agent prompt's documented "high or
  medium" requirement)
- estimator_abandon_side_effects.py: per-state side-effect tick
  modeled on grooming_side_effects.py; invokes close_act with
  cause=Cause.ESTIMATOR_ABANDON + event_type='estimator_abandon'
- _events.py: shared latest_transition_event +
  workflows_with_latest_transition_in helpers; dialect-aware
  payload['event'] extraction (SQLite json_extract +
  PostgreSQL ->>); centralizes the event_type='transition' +
  payload['event'] convention that side-effect ticks consume
- gate2_abandon_config.py: CONTROLLER_GATE2_ABANDON_ENABLED kill
  switch (default false). Fresh Phase 2 deploys are audit-only
  until operator explicitly enables; dry_run shared with grooming
  for unified safe-rollout staging
- .opencode/agents/estimator-implementation.md: GATE 2 ABANDON
  section with 9-category criteria + low_value disqualifier ("PR
  cites an issue/ticket -> route to reviewer instead")

Round-2 adversarial-review fixes (all required pre-commit):
- forgejo_writes.close_issue / close_act: NEW cause + event_type
  kwargs (defaults preserve Phase 1 grooming behavior; Phase 2
  callsite overrides). Fixes audit-trail attribution: telemetry
  queries SELECT WHERE cause='estimator_abandon' now return the
  right rows. Phase 1 regression test pins the grooming defaults
- tick.py operator_unstick lookback: dialect-aware json_extract
  fix (Phase 1 carry-over bug; would silently no-op on PostgreSQL)
- grooming_side_effects.py: idempotency filter now keys on
  check_name set (grooming check_names only) so a Phase 1 close
  and a Phase 2 close on the same workflow don't cross-cancel

Tests (+50): TestEstimatorOutputV1Phase2,
TestEstimatorAbandonStateMachine, TestMapEstimatorOutcomePhase2
(including confidence-low downgrade), TestEstimatorSetVerdict
(all 9 categories + cross-field rules), TestEventsHelper,
TestEstimatorAbandonSideEffectTick (including
test_close_writes_estimator_abandon_cause_and_event_type pinning
the audit-trail attribution, and Phase 1 regression guard).
Doc-contract test asserts all 9 categories appear in the agent
prompt. 1509/1509 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:44:09 -04:00

173 lines
6.3 KiB
Python

"""Shared helpers for reading ``controller_events`` rows.
The controller stores ALL state-machine transitions in
``controller_events`` with ``event_type='transition'`` and the actual
state-machine event name (e.g. ``'groom_verdict_defer'``,
``'estimator_abandon'``) in the JSON ``payload['event']`` field — see
``tick.py:451-462`` for the canonical writer. This is a load-bearing
convention: any per-state side-effect tick that needs to find
workflows by their most-recent transition must read it the same way.
Phase 1's grooming side-effect tick (``grooming_side_effects.py``)
hand-rolled the SQL: a window-function correlated subquery selecting
the most-recent row per workflow + ``json_extract(payload, '$.event')``.
Phase 2's estimator-abandon side-effect tick needs the same pattern;
Phase 3 (reviewer abandon) will too.
Rather than have each tick re-discover the convention (and the
related bug class — Phase 1's first build of the grooming tick
filtered on ``event_type`` literally and silently no-op'd because
real transitions are written with ``event_type='transition'``), this
module centralizes the read primitive.
See ``.drew/regressions-plan.md`` "Phase 1 follow-up backlog" for
the rationale + the Phase 2 prerequisite note.
"""
from __future__ import annotations
from typing import Iterable
from sqlalchemy import text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
def _sm_event_sql(dialect: str, column_expr: str) -> str:
"""Dialect-aware SQL fragment that extracts ``payload['event']``
from a JSON column.
- SQLite: ``json_extract(<col>, '$.event')``
- PostgreSQL: ``<col> ->> 'event'`` (works for both JSON and JSONB)
Pulled out as a helper so the per-tick SELECTs stay readable AND
the convention has exactly ONE place to grow if a future dialect
(or a future payload-shape change) needs to be supported. Phase 1
shipped with hardcoded ``json_extract`` everywhere, which silently
no-op'd in any PostgreSQL deployment — the test suite only ran
against SQLite. Phase 2 fixes that bug AND prevents the next
side-effect tick from re-introducing it.
"""
if dialect == "postgresql":
return f"{column_expr} ->> 'event'"
return f"json_extract({column_expr}, '$.event')"
def _dialect_name(session: Session) -> str:
"""Resolve the bind's dialect name, defaulting to sqlite if the
session isn't bound to an engine (e.g. some test setups)."""
return session.bind.dialect.name if session.bind else "sqlite"
def latest_transition_event(
session: Session, workflow_id: int
) -> tuple[str | None, str | None]:
"""Return ``(event_type, sm_event_name)`` for the most recent
``controller_events`` row for ``workflow_id``.
- ``event_type`` is the literal column value (e.g. ``'transition'``,
``'label-pause'``, ``'discovered'``, ``'lock-ttl-expired'``).
- ``sm_event_name`` is the state-machine event name when
``event_type == 'transition'`` (read from ``payload['event']``);
``None`` for all other event_types (which don't carry an
SM event name).
Returns ``(None, None)`` when the workflow has no events at all.
"""
sm_event_expr = _sm_event_sql(_dialect_name(session), "payload")
row = session.execute(
text(
f"""
SELECT event_type,
{sm_event_expr} AS sm_event
FROM controller_events
WHERE workflow_id = :wf_id
ORDER BY ts DESC, event_id DESC
LIMIT 1
"""
),
{"wf_id": workflow_id},
).first()
if row is None:
return (None, None)
return (row.event_type, row.sm_event)
def workflows_with_latest_transition_in(
session: Session,
sm_event_names: Iterable[str],
) -> list[tuple[int, str, str, int, str]]:
"""Return rows for every workflow whose MOST RECENT
``controller_events`` entry is a state-machine transition whose
``payload['event']`` matches one of ``sm_event_names``.
Returns a list of ``(workflow_id, owner, repo, entity_number,
sm_event_name)`` tuples. The (owner, repo, entity_number) tuple
lets the caller drive Forgejo calls without an extra SELECT per
workflow.
Used by per-state side-effect ticks (grooming, estimator-abandon,
reviewer-abandon) to find workflows whose state-machine just
transitioned to a state requiring Forgejo writes.
Idempotency: callers MUST gate on a separate "already-executed"
flag (e.g. ``grooming_decisions.executed = 1``) — this helper
only finds workflows in the right SM state; it does NOT
distinguish "needs side-effect to run" from "side-effect already
ran." See ``grooming_side_effects._process_one`` for the
standard pattern.
"""
sm_event_names = list(sm_event_names)
if not sm_event_names:
return []
# Build the IN-clause placeholders. We can't use the SQLAlchemy
# native ``in_`` here because the surrounding query is text-mode
# for the window function; bind each name positionally.
placeholders = ",".join(f":sm_event_{i}" for i in range(len(sm_event_names)))
params: dict[str, object] = {
f"sm_event_{i}": name for i, name in enumerate(sm_event_names)
}
sm_event_expr = _sm_event_sql(_dialect_name(session), "le.payload")
rows = session.execute(
text(
f"""
SELECT
w.workflow_id,
w.owner,
w.repo,
w.entity_number,
{sm_event_expr} AS sm_event
FROM workflows w
INNER JOIN (
SELECT
workflow_id,
event_type,
payload,
ts,
ROW_NUMBER() OVER (
PARTITION BY workflow_id
ORDER BY ts DESC, event_id DESC
) AS rn
FROM controller_events
) le
ON le.workflow_id = w.workflow_id
AND le.rn = 1
WHERE le.event_type = 'transition'
AND {sm_event_expr} IN ({placeholders})
"""
),
params,
).all()
return [
(r.workflow_id, r.owner, r.repo, r.entity_number, r.sm_event)
for r in rows
]
__all__ = [
"latest_transition_event",
"workflows_with_latest_transition_in",
]