"""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)