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>
1032 lines
38 KiB
Python
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"]
|