0bc734c020
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>
320 lines
13 KiB
Python
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)
|