Files
cleveragents-core/tools/_implementer_escalation.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

320 lines
13 KiB
Python

"""Escalation predicate for the implementer dispatcher's in-cycle
tier-walking loop.
The dispatcher (``tools/dispatch_implementer.py``) runs an
implementation worker session, observes the outcome, and asks this
module what to do next: stay on the current tier and retry, escalate
to the next tier, end the cycle, or succeed. The function is pure
(no I/O); the dispatcher owns side effects (claim refresh, worktree
reset, label mutation, status comments, sleep before retry).
See ``docs/development/implementer-in-cycle-escalation-plan.md`` for
the design rationale. Key invariants encoded here:
- **Outcome allow-list (PD P0):** only the literal string
``"resolved"`` short-circuits to ``SUCCESS``. Anything else
("success", "done", missing key, non-string truthy) flows into the
failure-class path. This protects against worker prompt variance
silently being treated as success.
- **head_sha_advanced is tri-state:** ``True`` = verified the worker
pushed; ``False`` = verified the worker did NOT push; ``None`` =
post-session fetch failed transiently. The ``None`` case for an
otherwise-successful outcome triggers ``RETRY_POST_FETCH`` — one
re-fetch attempt before either accepting the success or treating
it as a competence failure.
- **Silent worst case (PD P0):** ``head_sha_advanced=True`` with a
non-success outcome means the worker pushed at least one commit
and then crashed (transport-error mid-session, or no parsed JSON
at all). Escalating to a different tier would have the next
worker fetch and inherit the partial-progress commit, compounding
damage on a different model. End the cycle conservatively; the
next dispatcher cycle picks up with the partial progress as the
new baseline.
- **PR closed/merged mid-cycle → tier-stable:** escalation cannot
help a closed PR. End the cycle.
- **Same-tier retry is reserved for the transport class.** Competence
failures (rebase-failed, timeout, worker JSON outcome != resolved)
escalate immediately at the current tier's first failure —
re-running the same prompt against the same model is wasted
wallclock.
This module is loaded via the sibling-loader chain in
``dispatch_implementer.py``; it has no imports from other ``tools/``
modules so it can be unit-tested in isolation.
"""
from __future__ import annotations
import time
from enum import Enum
from typing import Callable, Mapping
class EscalationAction(Enum):
"""Outputs of :func:`decide`.
The dispatcher loop maps each value to a side effect:
- ``SUCCESS`` — end the cycle, clear attempt labels, no status
comment (worker posts its own resolved comment).
- ``RETRY_SAME_TIER`` — sleep for ``backoff(retry_attempt)``,
re-run the worker at the same tier without claim refresh or
worktree reset.
- ``RETRY_POST_FETCH`` — re-fetch ``head_sha_advanced`` once and
re-invoke :func:`decide`. No worker re-run.
- ``ESCALATE`` — apply next tier label, refresh claim, reset
worktree, re-run worker at ``current_tier + 1``.
- ``END_CYCLE`` — post a final status comment with a
tier-stable reason, clean up, end the cycle. Next dispatcher
cycle will pick the PR up fresh.
- ``EXHAUSTED`` — would-escalate but already at ``max_tier``.
Same side effects as ``END_CYCLE`` but with a different
``outcome_reason`` for the status comment so an operator can
distinguish "no more tiers" from "tier-stable failure".
"""
SUCCESS = "success"
RETRY_SAME_TIER = "retry_same_tier"
RETRY_POST_FETCH = "retry_post_fetch"
ESCALATE = "escalate"
END_CYCLE = "end_cycle"
EXHAUSTED = "exhausted"
class FailureClass(Enum):
"""Internal taxonomy used by :func:`classify_failure`.
Not surfaced in the dispatcher's archive — the
:class:`EscalationAction` is the public output. The class is
captured for the status-comment ``outcome_reason`` so an operator
can grep telemetry by failure shape.
"""
TRANSPORT = "transport"
COMPETENCE = "competence"
TIER_STABLE = "tier_stable"
UNKNOWN = "unknown"
# Same-tier retry budget per failure class. Transport gets 2 retries
# with linear backoff (rate-limit / transient 5xx). Competence gets
# 0 — same model + same prompt = same answer. Tier-stable gets 0
# because escalation cannot help. Unknown (no parsed JSON, ambiguous
# terminal state) gets 1 retry on the chance the worker process
# itself died.
BUDGET_PER_FAILURE_CLASS: Mapping[FailureClass, int] = {
FailureClass.TRANSPORT: 2,
FailureClass.COMPETENCE: 0,
FailureClass.TIER_STABLE: 0,
FailureClass.UNKNOWN: 1,
}
# Allow-list of worker outcome strings that count as success. Only
# the literal "resolved" is accepted (see PD P0 in the plan): worker
# prompt drift could surface "success" / "done" / "ok" which must
# NOT short-circuit to SUCCESS.
# Outcomes that signal the cycle is done — the dispatcher records
# them as SUCCESS and stops escalating. ``resolved`` is the worker's
# affirmative-finish signal. ``no_changes_needed`` is the
# dispatcher's deterministic short-circuit verdict (2026-05-13 P0
# path): the PR was already complete before the LLM was even called.
SUCCESS_OUTCOMES = frozenset({"resolved", "no_changes_needed"})
# Terminal-state buckets used by :func:`classify_failure`. Mirrors
# the dispatcher's existing :data:`_NON_PUSHING_TERMINAL_STATES` plus
# explicit names for the buckets the predicate cares about.
TRANSPORT_TERMINAL_STATES = frozenset({"transport-error"})
TIMEOUT_TERMINAL_STATES = frozenset({"timeout"})
# Worker-emitted outcome strings that signal a failure escalation
# cannot help: pre-commit hook failures (the hook itself is broken),
# the worker explicitly refusing on policy grounds, etc. Kept as a
# separate constant so the worker contract can grow new tier-stable
# outcomes without changing the predicate.
TIER_STABLE_OUTCOMES = frozenset({"hook-failed", "pre-commit-failed"})
def classify_failure(
parsed_json: dict | None,
terminal_state: str,
pr_state: str,
) -> FailureClass:
"""Map session output to a :class:`FailureClass`.
Order of checks matters:
1. PR state — if Forgejo says the PR is closed or merged, no
tier escalation will help.
2. Transport terminal state — independent of outcome JSON,
because a transport-class failure can drop the JSON before it
reaches us.
3. Worker outcome string — when present and known, classifies
directly (tier-stable for hook failures; competence for
rebase-failed and other non-success strings).
4. Timeout terminal state — competence class (model couldn't
finish in budget; a smarter model may; same model retried
won't).
5. Fall-through (no parsed JSON, no recognised terminal state)
— ``UNKNOWN``.
"""
if pr_state in ("closed", "merged"):
return FailureClass.TIER_STABLE
if terminal_state in TRANSPORT_TERMINAL_STATES:
return FailureClass.TRANSPORT
if isinstance(parsed_json, dict):
outcome = parsed_json.get("outcome")
if isinstance(outcome, str):
if outcome in TIER_STABLE_OUTCOMES:
return FailureClass.TIER_STABLE
if outcome and outcome not in SUCCESS_OUTCOMES:
return FailureClass.COMPETENCE
if terminal_state in TIMEOUT_TERMINAL_STATES:
return FailureClass.COMPETENCE
return FailureClass.UNKNOWN
def decide(
parsed_json: dict | None,
terminal_state: str,
head_sha_advanced: bool | None,
pr_state: str,
transport_retries_used: int,
current_tier: int,
max_tier: int,
) -> EscalationAction:
"""Decide the next action after a worker session completes.
The dispatcher loop is responsible for the side effects:
- On ``RETRY_SAME_TIER`` it must sleep ``backoff(n+1)`` and
re-invoke the worker without claim refresh or worktree reset.
- On ``RETRY_POST_FETCH`` it must re-fetch the PR's head_sha and
re-invoke ``decide`` with the updated tri-state value.
- On ``ESCALATE`` it must apply the next tier's label, call
``_claim_runtime.claim_pr`` to post a fresh TTL comment, reset
the worktree to the prefetched head_sha, and re-invoke the
worker at ``current_tier + 1``.
- On ``END_CYCLE`` / ``EXHAUSTED`` it must post a final status
comment, clean up, and end the cycle.
Args:
parsed_json: Worker's JSON output, or ``None`` if the worker
never emitted parseable JSON. Only ``parsed_json["outcome"]``
is inspected.
terminal_state: Session terminal state from
``_opencode_worker.run_session_blocking`` — typically
one of ``"completed"``, ``"timeout"``, ``"transport-error"``,
or ``"unknown"``.
head_sha_advanced: Tri-state from the dispatcher's
post-session head_sha fetch. ``True`` means the worker
pushed at least one commit. ``False`` means the worker
did not push. ``None`` means the fetch failed transiently
(treat ambiguously — re-fetch on success path, fall
through on failure path).
pr_state: Freshly-fetched PR state from Forgejo. Conservative
default on transient fetch failure is ``"open"`` so we
proceed with escalation rather than ending the cycle on
a transient 5xx (the dispatcher's fetch helper enforces
this).
transport_retries_used: Number of same-tier retries already
spent at ``current_tier``. The dispatcher resets this to
``0`` at each tier boundary.
current_tier: Tier index of the just-completed attempt
(0-based). ``0`` is the first attempt; ``max_tier`` is
the ceiling.
max_tier: Maximum tier index the dispatcher will walk. In
v1 this is ``1`` (Tier 0 → 1). When the
``IMPLEMENTER_ESCALATION_TIER2_ENABLED`` flag flips,
callers can pass ``2``.
Returns:
:class:`EscalationAction`. See the enum docstring for the
side effects the dispatcher must perform per value.
"""
outcome = parsed_json.get("outcome") if isinstance(parsed_json, dict) else None
is_success_outcome = isinstance(outcome, str) and outcome in SUCCESS_OUTCOMES
if is_success_outcome:
if head_sha_advanced is True:
return EscalationAction.SUCCESS
# ``no_changes_needed`` legitimately produces no push: the
# dispatcher's P0 short-circuit determined nothing was wrong
# to begin with. Treat as SUCCESS regardless of
# head_sha_advanced — there's nothing the worker could
# have done that the dispatcher hasn't already verified.
if outcome == "no_changes_needed":
return EscalationAction.SUCCESS
if head_sha_advanced is None:
return EscalationAction.RETRY_POST_FETCH
# head_sha_advanced is False: worker claims success but did
# NOT actually push. This is a competence failure, not
# transport — the worker emitted a complete-looking JSON
# while delivering nothing. Same model on same input will
# do the same thing; force escalation by reclassifying
# as COMPETENCE. Without this branch the drop-through hit
# the UNKNOWN bucket (budget=1) and wasted a same-tier
# retry, exactly the failure observed live on 2026-05-13
# (PR #30 attempts 1 and 3; PR #28 cycle 2 attempt 1).
#
# Bypass classify_failure entirely so the bookkeeping is
# explicit: skip the budget check and go straight to
# ESCALATE (or EXHAUSTED at ceiling).
if current_tier >= max_tier:
return EscalationAction.EXHAUSTED
return EscalationAction.ESCALATE
# Silent worst case: the worker pushed at least one commit and
# then failed before emitting a success outcome. Escalating would
# have Tier N+1 inherit the partial push via `git fetch` (the
# worktree reset only handles local state; the remote already
# has the commit). End the cycle so the next dispatcher cycle
# starts fresh with the partial progress as the new baseline.
if head_sha_advanced is True and not is_success_outcome:
return EscalationAction.END_CYCLE
failure_class = classify_failure(parsed_json, terminal_state, pr_state)
if failure_class == FailureClass.TIER_STABLE:
return EscalationAction.END_CYCLE
budget = BUDGET_PER_FAILURE_CLASS[failure_class]
if transport_retries_used < budget:
return EscalationAction.RETRY_SAME_TIER
if current_tier >= max_tier:
return EscalationAction.EXHAUSTED
return EscalationAction.ESCALATE
def backoff(retry_attempt: int) -> float:
"""Linear backoff in seconds for transport-class retries.
``retry_attempt`` is 1-based: the first retry sleeps
``backoff(1) == 2.0``, the second ``backoff(2) == 4.0``. Zero or
negative values return ``0.0`` so callers can pass a 0-based
counter without an off-by-one guard.
"""
if retry_attempt < 1:
return 0.0
return float(retry_attempt) * 2.0
def sleep_for_retry(
retry_attempt: int,
*,
sleep_fn: Callable[[float], None] = time.sleep,
) -> None:
"""Sleep before a same-tier retry.
The ``sleep_fn`` parameter is injectable so tests can pass a
no-op without monkey-patching ``time.sleep`` globally (which
interferes with other tests that legitimately sleep).
"""
delay = backoff(retry_attempt)
if delay > 0:
sleep_fn(delay)