Files
cleveragents-core/tools/controller/master/tick.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

1032 lines
38 KiB
Python

"""Master tick handler — advances state machine for completed attempts.
One tick:
1. Query workflow_attempts where status='complete' AND workflow has
NOT yet processed this attempt (we track via a "consumed_at"
column? No — for v1 we use a simpler heuristic: an attempt is
"unprocessed" if its workflow_attempts.finished_at is more recent
than the workflow's last_transition_at).
2. For each, look up the workflow + use ``map_outcome_to_event`` to
pick a state machine event.
3. Apply via ``apply_event``; update workflow.current_state;
insert controller_events row.
What's deliberately deferred to Phase 1d-3:
- Discovery (Forgejo poll for new PRs/issues)
- Forgejo writes (status comments, labels, merges)
- Per-workflow "what to enqueue next" logic (after a state
transition, the master schedules the next attempt; for v1
this commit only handles the transition itself)
- Reconciliation tick (DB↔Forgejo sync)
- MERGING state's Forgejo merge call
This tick is composable: the master's main loop will call this tick,
then the reaper, then the pickup guard, then sleep + repeat.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
from ..db.session import session_scope
from ..state_machine import (
IllegalTransitionError,
KNOWN_STATES,
TERMINAL_STATES,
apply_event,
)
from .outcomes import EventMapResult, map_outcome_to_event
logger = logging.getLogger(__name__)
@dataclass
class TickReport:
"""Per-tick summary."""
attempts_processed: int = 0
transitions_applied: int = 0
unmapped_attempts: list[tuple[int, str]] = field(default_factory=list)
# (attempt_id, reason) — operator-visible via tail-events
transitions_log: list[dict] = field(default_factory=list)
# Each entry: {workflow_id, from_state, to_state, event, attempt_id}
def run_tick(engine: Engine) -> TickReport:
"""One master tick: advance state for any complete attempts not
yet processed."""
report = TickReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
rows = session.execute(
text(
"SELECT a.attempt_id, a.workflow_id, a.role, a.tier, "
" a.output_payload, a.input_payload, a.status, "
" a.head_sha_before, a.head_sha_after, "
" a.pickup_count, "
" w.current_state, w.current_tier, w.last_transition_at "
" FROM workflow_attempts a "
" JOIN workflows w ON w.workflow_id = a.workflow_id "
" WHERE a.status IN ('complete', 'failed') "
" AND a.finished_at IS NOT NULL "
" AND a.finished_at > w.last_transition_at "
" AND w.current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR') "
" ORDER BY a.finished_at ASC"
)
).all()
for r in rows:
report.attempts_processed += 1
transitioned = _process_attempt(session, r, now)
if transitioned is None:
# No mappable event; log + skip.
continue
report.transitions_applied += 1
report.transitions_log.append(transitioned)
return report
# ─── per-row processing ───────────────────────────────────────────────
def _process_attempt(session, row, now: datetime) -> dict | None:
"""Apply one attempt's outcome to its workflow's state machine.
Returns the transition record on success, or None if no
transition was applied (unmapped outcome, illegal event, etc.).
"""
# 1. Validate the workflow's current state.
if row.current_state not in KNOWN_STATES:
logger.error(
"workflow %s has unknown current_state %r; transitioning to STUCK",
row.workflow_id,
row.current_state,
)
_transition_to_stuck(
session,
row.workflow_id,
row.current_state,
now,
reason="unknown-state",
attempt_id=row.attempt_id,
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": "STUCK",
"event": "(synthetic) unknown-state",
"attempt_id": row.attempt_id,
}
# 2. Decode output_payload + compute auxiliary context.
try:
output_payload = _decode_output_payload(row.output_payload)
except CorruptedOutputPayload as exc:
logger.error(
"tick: workflow %s attempt_id=%s has corrupted output_payload "
"(%s); routing to STUCK",
row.workflow_id,
row.attempt_id,
exc,
)
_transition_to_stuck(
session,
row.workflow_id,
row.current_state,
now,
reason="corrupted-output",
attempt_id=row.attempt_id,
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": "STUCK",
"event": "(synthetic) corrupted-output",
"attempt_id": row.attempt_id,
}
head_sha_advanced = (
bool(row.head_sha_after)
and bool(row.head_sha_before)
and row.head_sha_after != row.head_sha_before
)
# 3. Map outcome → event.
# attempts_remaining_at_tier + conflict_count_at_current_tier:
# for v1 we use a simple computation (count of attempts at this
# role+tier vs max_attempts on the workflow). The full policy
# lives in Phase 1d-3; v1 ships with a "≥1 remaining always"
# default which keeps the state machine in a simple loop until
# max_attempts kicks in via the pickup guard.
attempts_remaining_at_tier = 1
# operator_unstick epoch: a workflow the operator deliberately
# requeued starts its STUCK-gating backstop counters fresh, so a
# stale failure tally from before the requeue cannot immediately
# re-STUCK it. None ⇒ never requeued ⇒ count the whole history.
epoch_start = _epoch_started_at(session, row.workflow_id)
conflict_count_at_current_tier = _count_conflict_resolver_attempts(
session,
row.workflow_id,
row.current_tier,
epoch_start=epoch_start,
)
# E-1: count PRIOR (not including this attempt) contract-violation
# outcomes for this workflow+role. _map_failed_outcome uses it to
# gate the retry-once policy (spec promise; pre-fix the 1st
# violation went straight to STUCK).
prior_contract_violations = _count_prior_contract_violations(
session,
row.workflow_id,
row.role,
row.attempt_id,
epoch_start=epoch_start,
)
# T5-9: per-tier dispute cap (1 dispute per tier; subsequent
# dispute outcomes at the same tier downgrade to competence-failure).
prior_disputes_at_current_tier = _count_prior_disputes_at_tier(
session,
row.workflow_id,
row.tier,
row.attempt_id,
epoch_start=epoch_start,
)
# A: worker-internal-error count at this tier — escalate-on-repeated-
# timeout rule (map_outcome_to_event) reads this so a too-big-for-
# tier PR escalates instead of retrying the same tier to STUCK.
prior_worker_errors_at_tier = _count_prior_worker_errors_at_tier(
session,
row.workflow_id,
row.tier,
row.attempt_id,
epoch_start=epoch_start,
)
# ci-not-ready backstop count (per-workflow): map_outcome_to_event
# caps ci-not-ready so it cannot ping-pong with ci_red_retry forever.
prior_ci_not_ready = _count_prior_ci_not_ready(
session,
row.workflow_id,
row.attempt_id,
epoch_start=epoch_start,
)
# ci-infra-failure backstop count (per-workflow): caps the
# IMPLEMENTING → DISCOVERED → CI-freshness-gate loop in case the
# implementer keeps mis-classifying a real failure as infra.
prior_ci_infra_failure = _count_prior_ci_infra_failure(
session,
row.workflow_id,
row.attempt_id,
epoch_start=epoch_start,
)
# Estimator worker-internal-error cap — bounds the unbounded
# estimator re-enqueue loop (run-2 saw 174x on one workflow).
prior_estimator_worker_errors = _count_prior_estimator_worker_errors(
session,
row.workflow_id,
row.attempt_id,
epoch_start=epoch_start,
)
# Pre-push gate-failure cap (per-tier): the worker's deterministic
# lint+typecheck gate failed on the agent's commits. Bounds the
# gate-failed re-enqueue loop and escalates a tier that keeps
# producing gate-dirty code.
prior_gate_failed = _count_prior_implementer_gate_retries_at_tier(
session,
row.workflow_id,
row.tier,
row.attempt_id,
epoch_start=epoch_start,
)
# Push-time stale-input cap (per-workflow): the PR branch moved
# under the attempt. Re-prefetch is free until the branch is
# genuinely contended, then STUCK for operator attention.
prior_stale_input = _count_prior_stale_input(
session,
row.workflow_id,
row.attempt_id,
epoch_start=epoch_start,
)
# Green-CI noop guard: was THIS attempt dispatched against a
# fully-green CI? An implementer ``noop`` against a green CI is
# "nothing to fix" reported correctly — it must route the workflow
# forward (AWAITING_CI), not escalate it to ABANDONED.
attempt_saw_green_ci = _ci_summary_is_green(_input_ci_summary(row.input_payload))
# dispute-reviewer guard: a dispute is only legitimate when a
# reviewer has actually judged this workflow. Count completed
# reviewer attempts (epoch-scoped) so a fabricated dispute on a
# never-reviewed workflow cannot shortcut IMPLEMENTING → REVIEWING
# past the CI gate (the PR-44 incident).
prior_reviews = _count_prior_reviews(
session,
row.workflow_id,
epoch_start=epoch_start,
)
mapped: EventMapResult = map_outcome_to_event(
role=row.role,
current_state=row.current_state,
output_payload=output_payload,
status=row.status,
head_sha_advanced=head_sha_advanced,
attempts_remaining_at_tier=attempts_remaining_at_tier,
conflict_count_at_current_tier=conflict_count_at_current_tier,
prior_contract_violations=prior_contract_violations,
attempt_tier=row.tier,
workflow_current_tier=row.current_tier,
prior_disputes_at_current_tier=prior_disputes_at_current_tier,
prior_worker_errors_at_tier=prior_worker_errors_at_tier,
prior_ci_not_ready=prior_ci_not_ready,
prior_ci_infra_failure=prior_ci_infra_failure,
prior_estimator_worker_errors=prior_estimator_worker_errors,
prior_gate_failed=prior_gate_failed,
prior_stale_input=prior_stale_input,
attempt_saw_green_ci=attempt_saw_green_ci,
prior_reviews=prior_reviews,
)
if mapped.event_name is None:
# Bump the workflow's last_transition_at so we don't re-process
# the same attempt every tick.
session.execute(
text(
"UPDATE workflows SET last_transition_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": row.workflow_id},
)
logger.debug(
"attempt_id=%s no mappable event: %s",
row.attempt_id,
mapped.reason,
)
return None
# 4. Apply the event.
try:
to_state = apply_event(row.current_state, mapped.event_name)
except IllegalTransitionError as exc:
# E-5 fix (2026-05-19): not all illegal transitions are
# corruption — many are "stale outcome": the workflow advanced
# past this role's state (e.g., via ci_status_poll or
# reconciliation) between when this attempt was scheduled and
# when its outcome is being processed. In that case the
# outcome is OK-but-late; just consume it without STUCK.
#
# A stale outcome is detected by checking: is the event one
# this role would fire from a DIFFERENT state? (e.g.,
# estimator_done fires from ANALYZING; if workflow is now in
# IMPLEMENTING/REVIEWING/MERGING/MERGED, the estimator's
# outcome arrived after the workflow already advanced.)
if _is_stale_role_outcome(row.role, row.current_state):
logger.info(
"tick: workflow %s outcome from role=%r is stale "
"(workflow is now in %s, past this role's state); "
"consuming attempt without transition. event=%s",
row.workflow_id,
row.role,
row.current_state,
mapped.event_name,
)
session.execute(
text(
"UPDATE workflows SET last_transition_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": row.workflow_id},
)
return None
logger.warning(
"illegal transition for workflow %s: %s (event=%s); transitioning to STUCK",
row.workflow_id,
exc,
mapped.event_name,
)
_transition_to_stuck(
session,
row.workflow_id,
row.current_state,
now,
reason=f"illegal-event: {mapped.event_name}",
attempt_id=row.attempt_id,
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": "STUCK",
"event": mapped.event_name,
"attempt_id": row.attempt_id,
}
# 5. Compute per-event side-effects on workflow columns beyond
# ``current_state``. Three v1 fields ride along with specific
# transitions:
#
# - ``current_tier`` ← ``recommended_tier`` from the estimator's
# output, on either ``estimator_done`` or
# ``estimator_metadata_only``. Without this, downstream
# IMPLEMENTING attempts always run at the workflow's creation-
# time tier (typically 0) regardless of what the estimator
# recommended — making the estimator role purely informational.
# The metadata-only branch ALSO sets tier so that a subsequent
# reviewer→request-changes path lands the implementer at the
# right tier.
#
# - ``tier_last_succeeded`` ← ``current_tier`` on
# ``implementer_pushed`` (the success path). Read by
# ``merging.py`` on post-approval 409 conflicts to route the
# workflow back to IMPLEMENTING at the last-known-good tier.
# Pre-2026-05-19 this column had ZERO writers; the 409 path
# transitioned to ``IMPLEMENTING(tier=NULL)`` which the
# scheduler silently coerced to tier 0.
extra_set: list[str] = []
extra_params: dict[str, Any] = {}
if mapped.event_name in ("estimator_done", "estimator_metadata_only"):
rec_tier = (output_payload or {}).get("recommended_tier")
if isinstance(rec_tier, int) and rec_tier in (0, 1, 2):
# Deterministic diff-size floor. The estimator's structured
# recommended_tier can contradict its own reasoning — run-25
# observed a 387-file PR whose reasoning said "firmly Tier 2"
# emitted as tier 0, so a huge cross-subsystem change ran on
# the weakest model and dead-ended. The controller already
# knows the diff size, so floor the tier deterministically:
# a genuinely large PR can never start below the floor. The
# floor only ever RAISES the tier.
floor = _diff_size_tier_floor(_input_diff_summary(row.input_payload))
effective_tier = max(rec_tier, floor)
if effective_tier != rec_tier:
logger.warning(
"estimator attempt_id=%s recommended_tier=%d but diff "
"size floors to tier %d; starting at tier %d",
row.attempt_id,
rec_tier,
floor,
effective_tier,
)
extra_set.append("current_tier = :rec_tier")
extra_params["rec_tier"] = effective_tier
else:
logger.warning(
"estimator attempt_id=%s emitted invalid recommended_tier=%r; "
"leaving current_tier unchanged",
row.attempt_id,
rec_tier,
)
elif mapped.event_name == "implementer_pushed":
# Latch the successful tier so the merging-409 conflict path
# can recover to the last-known-good tier.
if row.current_tier is not None:
extra_set.append("tier_last_succeeded = :tls")
extra_params["tls"] = row.current_tier
extra_set_clause = ("," + ", ".join(extra_set)) if extra_set else ""
# 5. Commit the transition.
session.execute(
text(
"UPDATE workflows SET "
" current_state = :to_state, "
" last_transition_at = :now, "
" entered_state_at = :now"
f"{extra_set_clause} "
"WHERE workflow_id = :wf_id"
),
{
"to_state": to_state,
"now": now,
"wf_id": row.workflow_id,
**extra_params,
},
)
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'transition', :from_state, :to_state, "
" :aid, :payload, 0, 0)"
),
{
"wf_id": row.workflow_id,
"ts": now,
"from_state": row.current_state,
"to_state": to_state,
"aid": row.attempt_id,
"payload": json.dumps(
{
"event": mapped.event_name,
"reason": mapped.reason,
"role": row.role,
}
),
},
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": to_state,
"event": mapped.event_name,
"attempt_id": row.attempt_id,
}
# ─── helpers ──────────────────────────────────────────────────────────
def _is_stale_role_outcome(role: str, current_state: str) -> bool:
"""True if this role's outcome could only have come from a state
the workflow has already moved past. Used to distinguish
"stale-late outcome that's safe to skip" from "genuine state
corruption that warrants STUCK".
Stale-pattern: estimator only fires from ANALYZING. If the
workflow is in IMPLEMENTING/REVIEWING/MERGING/MERGED/STUCK/
AWAITING_CI/ABANDONED, the estimator outcome arrived too late
(it was scheduled, ran, finished — but in the meantime the
workflow advanced via a parallel path).
"""
role_to_origin_state = {
"estimator": "ANALYZING",
"implementer": "IMPLEMENTING",
"reviewer": "REVIEWING",
"conflict_resolver": "CONFLICT_RESOLVING",
"summarizer": None, # summarizer drives no transition; never stale-illegal
}
origin = role_to_origin_state.get(role)
if origin is None:
return False
return current_state != origin
class CorruptedOutputPayload(Exception):
"""Raised when the raw output_payload column is non-NULL but does
not decode as JSON. Distinguishes "no output" (None → mapper
returns no-op event) from "broken bytes" (workflow should STUCK
with reason='corrupted-output').
"""
def _decode_output_payload(raw) -> dict | None:
"""SQLite returns JSON columns as strings (when bound via text());
Postgres returns dicts. Normalize.
Raises CorruptedOutputPayload on decode failure for str inputs so
the caller can route the workflow to STUCK with a clear reason.
Pre-2026-05-19 this silently swallowed JSONDecodeError → mapper
returned None → tick bumped last_transition_at but never moved
the workflow (E-3: silent stuck-forever with no operator signal).
"""
if raw is None:
return None
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise CorruptedOutputPayload(
f"output_payload is not valid JSON: {exc}"
) from exc
return raw
# ─── diff-size tier floor (estimator self-contradiction guard) ────────
# A PR this large cannot be safely implemented at a weak tier
# regardless of the estimator's structured recommended_tier (run-25: a
# 387-file PR's reasoning said "Tier 2" but the emitted field was 0).
# Tuned conservatively — only genuinely big PRs trip a floor, and the
# floor never lowers a tier.
_TIER1_FLOOR_FILES = 40
_TIER1_FLOOR_LINES = 1_500
_TIER2_FLOOR_FILES = 120
_TIER2_FLOOR_LINES = 6_000
def _input_diff_summary(raw) -> str | None:
"""Pull ``diff_summary`` from an attempt's input_payload column.
Tolerates the SQLite-string vs Postgres-dict split + missing keys;
returns None on anything unparseable (→ no floor applied).
"""
if raw is None:
return None
payload: Any = raw
if isinstance(raw, str):
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
ds = payload.get("diff_summary")
return ds if isinstance(ds, str) else None
def _diff_size_tier_floor(diff_summary: str | None) -> int:
"""Minimum starting tier implied by the PR's diff size.
``diff_summary`` is the controller-built one-liner from
``prefetch._diff_summary_from_pr`` — e.g. ``"387 files, +32579,
-12900"``. Returns 0 (no floor) when the summary is absent or
unparseable, so a parse miss degrades to the estimator's own
recommendation rather than over-tiering.
"""
if not diff_summary:
return 0
files = 0
lines = 0
m = re.search(r"(\d+)\s+files?\b", diff_summary)
if m:
files = int(m.group(1))
for sign in (r"\+", "-"):
sm = re.search(rf"{sign}(\d+)", diff_summary)
if sm:
lines += int(sm.group(1))
if files >= _TIER2_FLOOR_FILES or lines >= _TIER2_FLOOR_LINES:
return 2
if files >= _TIER1_FLOOR_FILES or lines >= _TIER1_FLOOR_LINES:
return 1
return 0
# ─── green-CI noop guard ──────────────────────────────────────────────
def _input_ci_summary(raw) -> dict | None:
"""Pull the ``ci_summary`` dict from an attempt's input_payload.
Mirrors ``_input_diff_summary``: tolerates the SQLite-string vs
Postgres-dict split and missing keys; returns None on anything
unparseable so a parse miss degrades to "no CI seen".
"""
if raw is None:
return None
payload: Any = raw
if isinstance(raw, str):
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
ci = payload.get("ci_summary")
return ci if isinstance(ci, dict) else None
def _ci_summary_is_green(ci: dict | None) -> bool:
"""True iff a CISummary dict is an unambiguous all-gates-passed run.
Strict on purpose: requires overall success, zero failed gates,
zero pending gates, AND at least one passed gate — an empty or
unknown summary is NOT "green". Feeds the green-CI ``noop`` guard
in ``map_outcome_to_event``: an implementer that emits ``noop``
against a green CI is reporting "nothing to fix" correctly, so the
workflow should move forward rather than escalate to ABANDONED.
"""
if not isinstance(ci, dict):
return False
overall = str(ci.get("overall_state") or "").lower()
failed = ci.get("gates_failed")
pending = ci.get("gates_pending")
passed = ci.get("gates_passed")
return (
overall == "success"
and isinstance(failed, int)
and failed == 0
and isinstance(pending, int)
and pending == 0
and isinstance(passed, int)
and passed > 0
)
def _epoch_started_at(session, workflow_id: int) -> datetime | None:
"""Timestamp of the most recent ``operator_unstick`` for this
workflow, or None if it was never operator-requeued.
``operator_unstick`` is the deliberate operator escape hatch
(STUCK / APPROVED / PAUSED / OPERATOR_ATTENTION → DISCOVERED). It
marks a fresh start: the per-workflow STUCK-gating counters scope
to attempts created after this point, so a requeued workflow does
not inherit — and immediately re-trip — the failure tallies that
got it stuck in the first place. The automatic DISCOVERED loops
(ci_infra_recheck, ci_red_retry) are NOT operator_unstick events,
so their backstop caps still bound those loops correctly.
"""
# operator_unstick has no canonical writer (it is always an operator
# action), so it appears under two conventions in the wild: as a
# bare event_type, or as a 'transition' whose payload.event names
# it. Match both. The native controller never emits operator_unstick,
# so a payload.event match cannot collide with an automatic event.
#
# Dialect-aware payload['event'] extraction — see
# ``master/_events._sm_event_sql`` for the convention. Phase 2
# fix: SQLite ``json_extract`` doesn't exist on PostgreSQL; the
# original Phase 1 SQL would silently no-op (swallowed by the
# surrounding ``except`` handler) in any PG deployment.
from ._events import _sm_event_sql
sm_event_expr = _sm_event_sql(
session.bind.dialect.name if session.bind else "sqlite",
"payload",
)
rows = session.execute(
text(
f"SELECT ts FROM controller_events "
f"WHERE workflow_id = :wf_id "
f" AND (event_type = 'operator_unstick' "
f" OR {sm_event_expr} = 'operator_unstick')"
),
{"wf_id": workflow_id},
).fetchall()
epochs: list[datetime] = []
for r in rows:
ts = r.ts
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
epochs.append(ts)
return max(epochs) if epochs else None
def _epoch_filtered(sql: str, params: dict, epoch_start: datetime | None) -> str:
"""Append the operator-unstick epoch filter to a counter query.
When the workflow has been operator-requeued, only attempts created
after the requeue count toward the STUCK-gating backstop. Mutates
``params`` in place and returns the (possibly extended) SQL.
Both sides are wrapped in SQLite ``datetime()`` so the comparison is
immune to timestamp-format drift (``T`` vs space separator, presence
or absence of a tz offset) between rows written by the ORM and rows
written by ad-hoc operator tooling.
"""
if epoch_start is None:
return sql
params["epoch"] = epoch_start.isoformat()
return sql + " AND datetime(created_at) > datetime(:epoch)"
def _count_prior_contract_violations(
session,
workflow_id: int,
role: str,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count contract-violation outcomes for this workflow+role from
attempts OTHER than this one. Used by E-1 retry-budget gating —
a workflow gets ``_CONTRACT_VIOLATION_RETRY_LIMIT + 1`` total
contract-violation tolerance before STUCKing."""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id AND role = :role "
" AND outcome = 'contract-violation' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "role": role, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_disputes_at_tier(
session,
workflow_id: int,
tier: int | None,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count completed implementer attempts at this tier with
outcome=dispute-reviewer, EXCLUDING the attempt being processed.
Used by T5-9 dispute cap: a workflow gets exactly
``_MAX_DISPUTES_PER_TIER`` disputes at each tier before further
dispute outcomes are downgraded to competence-failure (which
escalates the workflow).
"""
if tier is None:
return 0
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND tier = :tier "
" AND outcome = 'dispute-reviewer' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "tier": tier, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_worker_errors_at_tier(
session,
workflow_id: int,
tier: int | None,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count implementer attempts at this tier that failed with
outcome=worker-internal-error (dominated by OpenCode session
timeouts), EXCLUDING the attempt being processed.
Feeds the A-escalation rule in ``map_outcome_to_event``: repeated
worker-internal-error at a tier escalates the workflow instead of
retrying that tier until pickup-exhaustion → STUCK.
"""
if tier is None:
return 0
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND tier = :tier "
" AND outcome = 'worker-internal-error' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "tier": tier, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_ci_not_ready(
session,
workflow_id: int,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count implementer attempts on this workflow with
outcome=ci-not-ready, EXCLUDING the attempt being processed.
Feeds the ci-not-ready backstop cap in ``map_outcome_to_event`` —
counted per-workflow (not per-tier) so the cap bounds the whole
ci-not-ready <-> ci_red_retry loop regardless of tier.
"""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND outcome = 'ci-not-ready' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_ci_infra_failure(
session,
workflow_id: int,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count implementer attempts on this workflow with
outcome=ci-infra-failure, EXCLUDING the attempt being processed.
Feeds the ci-infra-failure backstop cap in ``map_outcome_to_event``
— counted per-workflow so the cap bounds the whole IMPLEMENTING →
DISCOVERED → gate loop regardless of tier.
"""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND outcome = 'ci-infra-failure' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_estimator_worker_errors(
session,
workflow_id: int,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count estimator attempts on this workflow that failed with
outcome=worker-internal-error, EXCLUDING the attempt being processed.
Feeds the estimator worker-error cap in ``map_outcome_to_event``: an
estimator that never emits canonical output has no escalation path
and no salvage, so without a cap it re-enqueues forever (run-2
observed 174x on one workflow). Counted per-workflow — the estimator
runs pre-tier, so tier is not a meaningful axis here.
"""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'estimator' "
" AND outcome = 'worker-internal-error' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_implementer_gate_retries_at_tier(
session,
workflow_id: int,
tier: int | None,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count implementer attempts at this tier whose worker-run pre-push
lint+typecheck gate failed (outcome='gate-failed'), EXCLUDING the
attempt being processed.
Feeds the gate-retry cap in ``map_outcome_to_event``: a tier that
keeps producing gate-dirty code escalates instead of retrying the
same tier forever.
"""
if tier is None:
return 0
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND tier = :tier "
" AND outcome = 'gate-failed' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "tier": tier, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_stale_input(
session,
workflow_id: int,
this_attempt_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count attempts on this workflow that failed with
outcome='stale-input', EXCLUDING the attempt being processed.
Feeds the stale-input contention cap in ``map_outcome_to_event`` —
counted per-workflow (the PR branch, not a tier, is what's
contended).
"""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND outcome = 'stale-input' "
" AND attempt_id != :this_id"
)
params = {"wf_id": workflow_id, "this_id": this_attempt_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_prior_reviews(
session,
workflow_id: int,
epoch_start: datetime | None = None,
) -> int:
"""Count completed reviewer attempts on this workflow.
Feeds the dispute-reviewer guard in ``map_outcome_to_event``: an
implementer ``dispute-reviewer`` is only legitimate when a reviewer
has actually rendered a verdict to dispute. Counted per-workflow and
epoch-scoped — an ``operator_unstick`` is a fresh start, so a dispute
must reference a review from the current epoch, not a stale pre-requeue
one. No self-exclusion: the attempt being processed is an implementer
attempt, never a reviewer one.
"""
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'reviewer' "
" AND status = 'complete'"
)
params = {"wf_id": workflow_id}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return int(row.n) if row else 0
def _count_conflict_resolver_attempts(
session,
workflow_id: int,
current_tier: int | None,
epoch_start: datetime | None = None,
) -> int:
"""Count *resolved* conflict cycles at the current tier.
The v6 "3+ conflicts → STUCK" policy measures how many distinct
conflicts the resolver has actually *resolved*: each resolved
attempt is exactly one conflict cycle, since the workflow leaves
CONFLICT_RESOLVING on success and only re-enters when a *new*
conflict appears. Failed / blocked / errored attempts are retries
of the *same* unresolved conflict — bounded separately by the
pickup guard — and must not inflate this "structurally hard"
budget, or a burst of transient worker errors would STUCK a
workflow whose conflict was resolved on the very first real pass.
"""
if current_tier is None:
return 0
sql = (
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id AND role = 'conflict_resolver' "
" AND tier = :tier "
" AND status = 'complete' AND outcome = 'resolved'"
)
params = {"wf_id": workflow_id, "tier": current_tier}
sql = _epoch_filtered(sql, params, epoch_start)
row = session.execute(text(sql), params).first()
return row.n if row else 0
def _transition_to_stuck(
session,
workflow_id: int,
from_state: str,
now: datetime,
*,
reason: str,
attempt_id: int | None,
) -> None:
session.execute(
text(
"UPDATE workflows SET "
" current_state = 'STUCK', "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": workflow_id},
)
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'transition', :from_state, 'STUCK', "
" :aid, :payload, 0, 0)"
),
{
"wf_id": workflow_id,
"ts": now,
"from_state": from_state,
"aid": attempt_id,
"payload": json.dumps({"reason": reason}),
},
)
__all__ = ["TickReport", "run_tick"]