a91df787d7
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>
173 lines
6.3 KiB
Python
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",
|
|
]
|