Files
cleveragents-core/tools/dispatch_implementer.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

3966 lines
163 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Deterministic dispatcher for implementation workers.
The dispatcher preserves the priority order originally established by
the (now-decommissioned) ``implementation-supervisor.md`` LLM agent:
fix failing PRs first, then PRs with unaddressed review feedback, then
new issue work. The worker remains the LLM boundary; Python owns
queueing, PR claims, watchdogs, and telemetry. The legacy supervisor
agent was deleted on 2026-05-09 — see ``CHANGELOG.md`` and
``docs/development/auto-agents-tier-2-3-plan.md`` for the rationale —
and this dispatcher is now the only orchestrator for implementer work.
Phase 2 (2026-05-09) adds optional pre-fetch parity with
``dispatch_review.py``:
- ``IMPLEMENTER_DISPATCHER_PREFETCH=1`` swaps the legacy title-only
prompt for the rich pre-fetched prompt assembled in
:mod:`_implementer_prompt`. The worker reads PR description, diff,
CI status, comments, active REQUEST_CHANGES reviews, linked issues,
and the Epic body without issuing a single in-session GET.
- ``IMPLEMENTER_DISPATCHER_PRECLONE=1`` (Phase 3) additionally
pre-clones the PR's HEAD into ``/tmp/cleveragents-implementer-worktrees/``
so the worker can ``edit`` against the worktree without spawning
``git-isolator-util``.
Both flags default to ``0`` (legacy behaviour) until a Phase 4
acceptance run flips them to default-on. The substrate is built; the
flags ungate adoption per the plan in
``docs/development/auto-agents-tier-2-3-plan.md``.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from pathlib import Path
from typing import Any
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import ( # noqa: E402 type: ignore[import-not-found]
load_sibling as _load_sibling,
)
_dispatch = _load_sibling("_dispatch_runtime", "_dispatch_runtime.py")
_claim_runtime = _load_sibling("_claim_runtime", "_claim_runtime.py")
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
_pr_clone = _load_sibling("_pr_clone", "_pr_clone.py")
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
_review_post = _load_sibling("_review_post", "_review_post.py")
_phase4_telemetry = _load_sibling("_phase4_telemetry", "_phase4_telemetry.py")
_implementer_prefetch = _load_sibling(
"_implementer_prefetch", "_implementer_prefetch.py"
)
_block_store = _load_sibling("_block_store", "_block_store.py")
_escalation_helpers = _load_sibling(
"_implementer_escalation_helpers",
"_implementer_escalation_helpers.py",
)
_implementer_metadata_classifier = _load_sibling(
"_implementer_metadata_classifier", "_implementer_metadata_classifier.py"
)
_implementer_prompt = _load_sibling("_implementer_prompt", "_implementer_prompt.py")
_pr_context_sentinel = _load_sibling("_pr_context_sentinel", "_pr_context_sentinel.py")
_worker_infra_seed = _load_sibling("_worker_infra_seed", "_worker_infra_seed.py")
_implementer_escalation = _load_sibling(
"_implementer_escalation", "_implementer_escalation.py"
)
_implementer_label_state = _load_sibling(
"_implementer_label_state", "_implementer_label_state.py"
)
_implementer_compliance = _load_sibling(
"_implementer_compliance", "_implementer_compliance.py"
)
_implementer_compliance_apply = _load_sibling(
"_implementer_compliance_apply", "_implementer_compliance_apply.py"
)
_recent_push_cache = _load_sibling("_recent_push_cache", "_recent_push_cache.py")
_implementer_gate_preflight = _load_sibling(
"_implementer_gate_preflight", "_implementer_gate_preflight.py"
)
_opencode_worker = _load_sibling("_opencode_worker", "_opencode_worker.py")
_sync_tier_models = _load_sibling("sync_tier_models", "sync_tier_models.py")
_logger = logging.getLogger("dispatch_implementer")
DRIVER_NAME = "dispatch_implementer.py"
# Item-context key carrying the resolved per-cycle worker-agent name
# (a task-implementor-tier-N variant, see ``tools/sync_tier_models.py``).
# Set by :func:`_implementation_prompt_dispatch` after tier resolution;
# read by ``_dispatch_runtime.dispatch_one`` and :func:`_run_worker_at_tier`
# so each tier maps to the matching pre-generated agent slot. The key
# replaces the old ``worker_agent="tier-dispatcher"`` static dispatch
# wired by the (now-retired) wrapper chain.
#
# Single source of truth lives in ``_dispatch_runtime`` (the layer that
# reads it). Re-exported here so callers in this module use the same
# string literal — a rename in either file would otherwise silently
# break the override path.
_WORKER_AGENT_OVERRIDE_KEY = _dispatch.WORKER_AGENT_OVERRIDE_ITEM_KEY
# Default starting tier when neither an explicit hint nor a confident
# estimator recommendation is available. Mirrors the legacy
# ``tier-dispatcher`` rule "default to tier 0 on is_confident=false".
_DEFAULT_START_TIER = 0
# Wallclock budget for the Python-side estimator call. Live cycles
# observed the LLM estimator finishing in 30-60s; cap at 180s so a
# stalled estimator cannot strand the whole dispatch cycle.
_ESTIMATOR_TIMEOUT_SECONDS = int(
os.environ.get("IMPLEMENTER_ESTIMATOR_TIMEOUT_SECONDS", "180")
)
# In-process cache for estimator results keyed by ``(pr_number,
# head_sha)``. The dispatcher process is long-lived (cycles every
# 120 s by default); without the cache, a PR that sits in the queue
# across multiple cycles WITHOUT producing an
# ``auto/last-attempt-tier-N`` label (a label-mechanism failure mode
# the run-15 doom-spiral exposed) would re-pay the estimator's
# 30-180 s wall-clock on every cycle to confirm the same answer.
# The cache short-circuits that. Invalidated three ways:
# 1. ``head_sha`` changes (new commit on the PR) — implicit, the
# get() returns miss because the cached SHA doesn't match.
# 2. TTL expires (default 1 h) — belt-and-braces for the
# no-new-commit case.
# 3. The cycle's worker session reports a non-success outcome
# (failure, timeout, transport error) —
# :func:`_invalidate_estimator_cache_on_failure` is called from
# the post-session action so the next cycle re-asks the
# estimator with the newly-written attempt-history digest in
# the prompt. Closes the doom-loop gap where a failed tier
# recommendation could be cached and re-served until TTL.
#
# Layout: ``{pr_number: (head_sha, tier_or_none, computed_monotonic)}``.
# ``tier_or_none`` IS the cached value — both confident and
# no-confidence outcomes are cached so we don't burn the estimator
# repeatedly on the same null-result case either.
#
# Concurrency: the dispatch loop is single-threaded; this dict is
# accessed only from that loop's call stack (prompt factory →
# estimator call → post-session action). If a future change
# introduces async / concurrent cycles, this dict needs a lock.
_ESTIMATOR_CACHE: dict[int, tuple[str, int | None, float]] = {}
_ESTIMATOR_CACHE_TTL_S = int(
os.environ.get("IMPLEMENTER_ESTIMATOR_CACHE_TTL_S", "3600")
)
def _estimator_cache_get(pr_number: int, head_sha: str) -> tuple[bool, int | None]:
"""Return ``(hit, tier)``.
``hit=True`` means the cache had a fresh entry for this
``(pr_number, head_sha)`` and ``tier`` is the cached value
(may be ``None`` if the estimator was not confident last time).
``hit=False`` means the caller should run the estimator.
"""
entry = _ESTIMATOR_CACHE.get(pr_number)
if entry is None:
return (False, None)
cached_sha, cached_tier, computed_at = entry
if cached_sha != head_sha:
return (False, None)
if (time.monotonic() - computed_at) > _ESTIMATOR_CACHE_TTL_S:
return (False, None)
return (True, cached_tier)
def _estimator_cache_put(
pr_number: int,
head_sha: str,
tier: int | None,
) -> None:
_ESTIMATOR_CACHE[pr_number] = (head_sha, tier, time.monotonic())
def _estimator_cache_clear() -> None:
"""Reset the in-process estimator cache. Used by tests AND by
:func:`_invalidate_estimator_cache_on_failure` (via the
single-PR ``_estimator_cache_drop_pr``); also safe to call from
operator tooling that needs to force a re-estimate."""
_ESTIMATOR_CACHE.clear()
def _estimator_cache_drop_pr(pr_number: int) -> bool:
"""Remove the cache entry for ``pr_number`` if present. Returns
True when an entry was dropped, False if no entry existed.
Called from :func:`_invalidate_estimator_cache_on_failure` when
a worker session reports a non-success outcome — we want the
next cycle to re-ask the estimator with the updated
attempt-history digest rather than serve the now-disproven
cached tier recommendation.
"""
return _ESTIMATOR_CACHE.pop(pr_number, None) is not None
CLAIM_KIND = "implementer"
PREFETCH_ENV_VAR = "IMPLEMENTER_DISPATCHER_PREFETCH"
PRECLONE_ENV_VAR = "IMPLEMENTER_DISPATCHER_PRECLONE"
# In-cycle tier escalation (2026-05-12). When ON, the dispatcher runs
# the worker for Tier 0; if the worker fails in a way that escalation
# can help (see ``_implementer_escalation.decide``), the dispatcher
# re-runs the worker at Tier 1 within the same cycle — holding the
# claim throughout, refreshing TTL between attempts, resetting the
# worktree to the prefetched head_sha, and mutating the
# ``auto/last-attempt-tier-N`` labels for operator visibility. See
# ``docs/development/implementer-in-cycle-escalation-plan.md``.
#
# Default OFF: when unset (or falsy), the dispatcher runs in the
# pre-feature single-shot mode, byte-equivalent to the behaviour
# shipped before this commit.
ESCALATION_ENABLED_ENV_VAR = "IMPLEMENTER_ESCALATION_ENABLED"
# Tier 2 (``tier-2`` slot) kill-switch. Consulted only when
# ``IMPLEMENTER_ESCALATION_ENABLED`` is ON. **Default since
# 2026-05-12: ON.** Earlier iterations gated Tier 2 behind an opt-in
# flag, but that defeats the purpose of escalation — if Tier 1 fails,
# the PR stays stuck without trying the next model. The flag is now
# an opt-OUT kill-switch: set
# ``IMPLEMENTER_ESCALATION_TIER2_ENABLED=0`` to stop escalation at
# Tier 1 (used as a panic button if the Tier 2 model's wallclock
# turns out to be operationally painful in practice). The specific
# model behind the ``tier-2`` slot is configured in
# ``.opencode/models/tiers.yaml`` and can be swapped without code
# changes — the kill-switch is slot-based, not model-based.
#
# Operational impact: worst-case cycle wallclock rises from
# ``2 × worker_timeout_seconds`` (Tier 0→1 only) to
# ``3 × worker_timeout_seconds`` (~6 h). The claim's TTL refresh
# at each tier boundary keeps the merge-driver sweep from
# reclaiming the PR mid-cycle.
ESCALATION_TIER2_ENABLED_ENV_VAR = "IMPLEMENTER_ESCALATION_TIER2_ENABLED"
# Outcome-synthesis kill-switch. The escalation loop synthesises a
# concrete outcome (rebase-failed / timeout / transport-error) when
# the worker dies before emitting JSON, so the escalation predicate
# sees a competence-class signal instead of falling into the UNKNOWN
# bucket.
#
# **Default since 2026-05-13: ON when escalation is enabled.** Split
# from ``IMPLEMENTER_ESCALATION_ENABLED`` so an operator who suspects
# the synthesis is mis-routing a particular failure class can disable
# it without turning off the whole escalation feature. Set
# ``IMPLEMENTER_OUTCOME_SYNTHESIS=0`` to revert to the legacy
# "UNKNOWN-bucket wastes one retry" behaviour.
#
# **Operational cost when set to 0:** the escalation predicate sees
# UNKNOWN, which costs one wasted same-tier retry per cycle (the
# worker is re-spawned with the same tier hint and almost always
# fails the same way). Worst-case cycle wallclock rises by one
# worker_timeout (typically ~7200 s). Operators flipping this off
# should expect the cost and treat the flag as a panic-button, not
# a default-state.
OUTCOME_SYNTHESIS_ENV_VAR = "IMPLEMENTER_OUTCOME_SYNTHESIS"
# Compliance-gap detection kill-switch. The compliance section is
# computed by :func:`_compute_deterministic_sections` and serialised
# into the sentinel for the worker to read via
# ``implementer_pr_context.py --field compliance_gaps``.
#
# **Default since 2026-05-13: ON whenever escalation is enabled.**
# Set ``IMPLEMENTER_COMPLIANCE_GAPS_ENABLED=0`` to opt out.
#
# **Gating semantics: AND-only.** The compliance scan only runs when
# BOTH ``IMPLEMENTER_ESCALATION_ENABLED`` is on AND this flag is
# non-falsy. Setting this flag ON with escalation OFF does NOT
# enable the scan in isolation — compliance is meaningless outside
# the escalation loop's gap-filling flow (the worker has no retry
# path to fix discovered gaps). This flag is a kill-switch for the
# escalation-on case, not an independent feature toggle.
COMPLIANCE_GAPS_ENABLED_ENV_VAR = "IMPLEMENTER_COMPLIANCE_GAPS_ENABLED"
# Auto-fix kill switch. When this flag is non-falsy, the dispatcher
# applies trivial PR-compliance fixes (CONTRIBUTORS line, CHANGELOG
# stub, ISSUES CLOSED footer) deterministically without spawning the
# LLM worker. The worker is still called when there's real code work
# to do (failing tests, request-changes review feedback). Off-by-
# default because giving the dispatcher commit/push authority is a
# real responsibility expansion and operators should opt in.
#
# When this flag is OFF (default), the worker handles compliance
# fixes the way it has historically — slower and more expensive but
# with the LLM in the loop as a safety check.
#
# Operational consequences when set to 1:
# - ~5-13 min of LLM time saved per "metadata-only PR" cycle
# - Dispatcher pushes commits authored by the configured
# ``GIT_USER_NAME`` / ``GIT_USER_EMAIL`` identity
# - Cycle archive's ``post_session_result`` gains an
# ``auto_fix_report`` entry describing what was applied
AUTO_FIX_COMPLIANCE_ENV_VAR = "IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE"
# Estimator-driven adaptive tier selection (G11 harvest 2026-05-15).
# Default OFF. When OFF, every first-attempt invocation runs at
# tier-0 (the cheap-to-default slot) byte-for-byte to the pre-G11
# build: the dispatcher emits an explicit
# ``escalation_tier_hint: `0` `` line and ``tier-dispatcher`` skips
# the estimator. When ON, the dispatcher OMITS the hint on true
# first attempts (no prior ``auto/last-attempt-tier-N`` label, no
# in-cycle escalation already active), so ``tier-dispatcher`` falls
# through to ``estimator-implementation`` and a confident
# capability estimate decides the starting tier (-1..2). Cross-cycle
# resumption and in-cycle escalation continue to short-circuit the
# estimator by emitting their respective hints — the estimator is
# only consulted on TRUE first attempts.
ESTIMATOR_ENABLED_ENV_VAR = "IMPLEMENTER_ESTIMATOR_ENABLED"
# W8 harvest (2026-05-15) — loud-signal flag for degraded prompt
# assembly. When ON, the dispatcher emits a WARN log line naming
# the specific missing sections at the end of prompt assembly so an
# operator tailing the dispatcher log sees the silent-degradation
# signal without having to inspect telemetry JSONL. Default OFF
# preserves today's quiet behaviour byte-for-byte. The completeness
# signal is ALWAYS stashed on the per-item context (regardless of
# this flag) so future telemetry / status-comment paths can pick it
# up without needing operators to toggle the flag.
DEGRADED_PROMPT_LOG_ENV_VAR = "IMPLEMENTER_DEGRADED_PROMPT_LOG_ENABLED"
def _work_type_for_group(group_name: str) -> str:
"""Map dispatcher work-group names to the ``work_type`` constant
that appears in the worker prompt header (see
:func:`_implementer_prompt.assemble_header`). Kept here rather
than imported because the prompt builder derives this from
``group.item_kind``; the sentinel needs the same value but
receives the group name only.
Falls back to ``"pr_fix"`` for unrecognised groups so a future
work-group addition doesn't crash the sentinel write — the worst
that happens is the sentinel reports a wrong ``work_type``,
which the worker can sanity-check against its prompt header.
"""
if group_name == "new_issue":
return "issue_impl"
return "pr_fix"
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
def _env_truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES
_FALSY_ENV_VALUES = frozenset({"0", "false", "no", "off"})
def _env_falsy_explicit(name: str) -> bool:
"""``True`` when ``name`` is set to one of the falsy literals.
Used by :func:`_is_prefetch_enabled` and
:func:`_is_preclone_enabled` to support the default-ON / opt-out
model (set ``IMPLEMENTER_DISPATCHER_PREFETCH=0`` to explicitly
disable the rich prompt path for one cycle). An unset variable is
NOT falsy here — it falls through to the default.
"""
return os.environ.get(name, "").strip().lower() in _FALSY_ENV_VALUES
def _is_prefetch_enabled() -> bool:
"""Return ``True`` when the dispatcher should use the rich
pre-fetched prompt (PR description, diff, CI status, comments,
linked issues, etc.).
**Default since 2026-05-10: ON.** The post-mortem of the
PR #30 implementer runs showed that without prefetch the
``task-implementor`` subagent burns ~3-4 minutes on
``curl`` + ``read`` calls just to discover the PR's head SHA,
base branch, and changed files — all of which the dispatcher
already has from its own Forgejo calls. The rich prompt
eliminates those turns entirely. The legacy title-only prompt
remains available behind an explicit
``IMPLEMENTER_DISPATCHER_PREFETCH=0`` opt-out (e.g. for
bisecting a regression against the pre-Phase-4 behaviour).
"""
if _env_falsy_explicit(PREFETCH_ENV_VAR):
return False
return True
def _is_escalation_enabled() -> bool:
"""Return ``True`` when the in-cycle tier escalation loop is on.
Default OFF (per the plan's safety contract). When OFF, the
dispatcher's post_session_action runs the legacy single-shot
behaviour byte-equivalent to the pre-feature build. When ON for
PR-shaped items, the post_session_action delegates to
:func:`_post_session_action_with_escalation`, which observes the
worker's outcome and, when escalation can help, re-dispatches at
the next tier without releasing the implementer claim. See
``docs/development/implementer-in-cycle-escalation-plan.md``.
"""
return _env_truthy(ESCALATION_ENABLED_ENV_VAR)
def _is_degraded_prompt_log_enabled() -> bool:
"""W8 harvest (2026-05-15): default OFF. When set to a truthy
literal, the dispatcher emits a WARN log line naming the
missing prefetch sections after each cycle where prefetch
returned incomplete data."""
return _env_truthy(DEGRADED_PROMPT_LOG_ENV_VAR)
def _is_implementer_estimator_enabled() -> bool:
"""Return ``True`` when first-attempt tier selection should defer
to ``estimator-implementation`` instead of hard-coding tier-0.
Default OFF. When OFF, the dispatcher emits an explicit
``escalation_tier_hint: `0` `` line on every true first attempt
so the worker's ``tier-dispatcher`` short-circuits to the
default slot — byte-for-byte to today's behaviour. When ON, the
dispatcher OMITS the hint on first attempts (no
``auto/last-attempt-tier-N`` label and no in-cycle escalation
seeding ``start_tier > 0``), letting ``tier-dispatcher`` run the
estimator and choose an adaptive tier in ``-1`` .. ``2``.
See ``docs/development/final-working-harvest-plan.md`` (G11).
"""
return _env_truthy(ESTIMATOR_ENABLED_ENV_VAR)
def _is_outcome_synthesis_enabled() -> bool:
"""Return ``True`` when the dispatcher should synthesise an
outcome JSON for workers that exited without emitting one.
Default-ON when ``IMPLEMENTER_OUTCOME_SYNTHESIS`` is unset; set
to a falsy literal (``0`` / ``false`` / ``no`` / ``off``) to
revert to legacy behaviour. Independent of
:func:`_is_escalation_enabled` so the kill-switch can be flipped
without disabling the whole escalation feature. The legacy path
(``_post_session_action`` for non-PR work / when escalation is
off) never calls the synthesiser regardless of this flag.
"""
if _env_falsy_explicit(OUTCOME_SYNTHESIS_ENV_VAR):
return False
return True
def _is_compliance_gaps_enabled() -> bool:
"""Return ``True`` when the dispatcher should run the compliance
gap detector and serialise the result into the sentinel.
Default-ON when ``IMPLEMENTER_COMPLIANCE_GAPS_ENABLED`` is unset
(and escalation is enabled — the caller's responsibility); set
to a falsy literal to opt out. The split from
:func:`_is_escalation_enabled` lets an operator disable just
the compliance scan if e.g. ``git`` is unreliable on the
worktree, without losing tier escalation.
"""
if _env_falsy_explicit(COMPLIANCE_GAPS_ENABLED_ENV_VAR):
return False
return True
def _is_auto_fix_compliance_enabled() -> bool:
"""Return ``True`` when the dispatcher should apply deterministic
compliance fixes (CONTRIBUTORS line, CHANGELOG stub, ISSUES CLOSED
footer) without spawning the LLM worker.
**Default OFF.** Enabling this gives the dispatcher commit/push
authority for trivial PR-hygiene changes. Set
``IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1`` to opt in.
See :data:`AUTO_FIX_COMPLIANCE_ENV_VAR` for full rationale.
"""
return _env_truthy(AUTO_FIX_COMPLIANCE_ENV_VAR)
def _is_tier2_enabled() -> bool:
"""Return ``True`` when Tier 2 (the ``tier-2`` slot) is part of
the escalation ladder.
Consulted only when :func:`_is_escalation_enabled` is True.
**Default since 2026-05-12: ON.** The flag is now an opt-OUT
kill-switch — set ``IMPLEMENTER_ESCALATION_TIER2_ENABLED=0`` to
cap the ladder at Tier 1 (used as a panic button if whatever
model currently occupies the ``tier-2`` slot proves
operationally painful). The earlier opt-IN default defeated the
purpose of escalation because a Tier-1 failure would leave the
PR stuck without ever trying the next model.
"""
return not _env_falsy_explicit(ESCALATION_TIER2_ENABLED_ENV_VAR)
def _max_tier_for_cycle() -> int:
"""Maximum tier index the escalation loop will walk.
Returns ``2`` (Tier 0 → 1 → 2) by default. Returns ``1`` only
when an operator has explicitly set the Tier-2 kill-switch
(``IMPLEMENTER_ESCALATION_TIER2_ENABLED=0``).
"""
return 2 if _is_tier2_enabled() else 1
def _is_preclone_enabled() -> bool:
"""Return ``True`` when the dispatcher should pre-clone the PR's
head into ``/tmp/<repo>`` and pass the working-copy path to the
worker.
**Default since 2026-05-10: ON.** Symmetric to
:func:`_is_prefetch_enabled`: the pre-clone eliminates the
``git-isolator-util`` subagent's clone-then-checkout dance for
``pr_fix`` work (the typical ~60-100 s of network + git I/O).
The worker's prompt carries the ``## Pre-cloned working copy``
section with a working ``repo_dir`` path so the worker proceeds
directly to the patch step. Set
``IMPLEMENTER_DISPATCHER_PRECLONE=0`` to fall back to the
in-session clone via ``git-isolator-util``.
"""
if _env_falsy_explicit(PRECLONE_ENV_VAR):
return False
return True
# ─── Legacy prompt (preserved for ``IMPLEMENTER_DISPATCHER_PREFETCH=0``) ────
def _legacy_implementation_prompt(cfg: Any, item: dict[str, Any], group: Any) -> str:
"""Title-only prompt produced by the dispatcher before Phase 2.
Kept available so an operator who flips
``IMPLEMENTER_DISPATCHER_PREFETCH=0`` (Phase 4 rollback) gets the
same prompt shape the worker historically saw — Forgejo GETs +
``git-isolator-util`` clones inside the session.
Header assembly is delegated to
:func:`_implementer_prompt.assemble_header` so a future tweak to
the per-section heading wording (forgejo_url / work_type
template, claim notes, PR Compliance Checklist) lands in one
place and propagates to both the legacy and prefetch branches.
The output-JSON contract is appended verbatim from
:data:`_implementer_prompt.OUTPUT_CONTRACT`. The final ``\\n`` is
preserved for byte-equivalence with the historical snapshot
(operators relying on ``--once`` log greps key off the trailing
newline).
The header builder + claim-note constants are public symbols on
:mod:`_implementer_prompt`; cross-module access is intentional
rather than a privacy pierce.
"""
claim_note = (
_implementer_prompt.PR_CLAIM_NOTE
if group.item_kind != "issue"
else _implementer_prompt.ISSUE_CLAIM_NOTE
)
header = _implementer_prompt.assemble_header(cfg, item, group, claim_note)
return f"{header}\n\n{_implementer_prompt.OUTPUT_CONTRACT}\n"
# ─── Pre-fetch + pre-clone prompt (Phase 2 + 3 path) ────────────────────────
def _head_ref_from_result(result: Any) -> str:
"""Pick the PR's head-branch short name out of the prefetch
result without raising when any layer of the
``pr_details.head.ref`` chain is absent. Returns ``""`` when
the dispatcher's PR-details fetch failed; the empty branch
field is harmless (worker falls back to its prompt-derived
name)."""
pr_details = getattr(result, "pr_details", None) or {}
head = pr_details.get("head") if isinstance(pr_details, dict) else None
if not isinstance(head, dict):
return ""
ref = head.get("ref")
return ref if isinstance(ref, str) else ""
def _build_clone_section(
cfg: Any,
pr_number: int,
head_sha: str,
*,
head_ref: str = "",
) -> tuple[str, Any]:
"""Run the Phase 3 pre-clone (when ``IMPLEMENTER_DISPATCHER_PRECLONE=1``)
and return ``(section_text, clone_handle)``.
``head_ref`` is the PR's head-branch short name, sourced from
the dispatcher's freshly-fetched ``pr_details.head.ref``. It
flows through to the workspace handoff sentinel's ``branch``
field so the worker can ``git push`` back to the right branch
without an extra Forgejo GET. Empty string is fine — the
sentinel records an empty branch and the worker falls back to
its prompt-derived branch name.
On dry-run / disabled / failure: returns the no-handle stanza
text and a ``None`` handle. The dispatcher's finally block treats
``None`` as "nothing to clean up" and the worker prompt's
``## Pre-cloned working copy`` section text already documents
the fall-through to ``git-isolator-util``.
Why this lives here rather than in :mod:`_implementer_prompt`:
the prompt builder is pure (no I/O), so it can be unit-tested
without monkeypatching git / the network. The clone is a side
effect that needs to live in the dispatcher driver where the
cleanup hook also lives.
"""
if cfg.dry_run or not _is_preclone_enabled() or not head_sha:
return _pr_diff.build_clone_section(None, head_sha or ""), None
handle = _pr_clone.prepare_pr_worktree(
cfg, pr_number, head_sha, head_ref=head_ref, kind="implementer"
)
return _pr_diff.build_clone_section(handle, head_sha or ""), handle
def _rebuild_prompt_from_cached_result(
cfg: Any,
item: dict[str, Any],
group: Any,
cached_result: Any,
cached_clone: Any,
) -> str | None:
"""Re-render the prompt text from a cached prefetch result.
B3 (2026-05-13): the escalation loop's ESCALATE branch was
re-running the entire prefetch + gate-preflight + compliance
scan for every tier transition (~7 min per transition on a
comment-heavy PR). The prefetch result and det_sections don't
change between tiers within a single cycle — the worktree
reset puts everything back at the prefetched head_sha — so
rebuild the prompt TEXT from cached pieces and skip the I/O.
Returns the prompt string on cache hit; ``None`` if the
cached pieces are incomplete (caller falls back to a full
prefetch).
Note: the sentinel write + det_sections compute also runs
inside the cached path so a Tier 1+ attempt sees up-to-date
on-disk handoff. The expensive part — Forgejo fetches,
bare-mirror refresh, gate-preflight — is what we skip.
"""
if cached_result is None:
return None
clone_section = ""
if cached_clone is not None:
# Same shape as _build_clone_section but without re-cloning.
try:
clone_section = _pr_diff.build_clone_section(
cached_clone,
getattr(cached_result, "head_sha", "") or "",
)
except Exception as exc:
_logger.warning(
"B3 cache path: failed to render clone section from "
"cached handle; falling through to full prefetch: %s",
exc,
)
return None
if group.name == "failing_ci_pr":
text = _implementer_prompt.build_pr_fix_prompt(
cfg,
item,
group,
cached_result,
clone_section,
)
elif group.name == "request_changes_pr":
text = _implementer_prompt.build_request_changes_prompt(
cfg,
item,
group,
cached_result,
clone_section,
)
elif group.name == "new_issue":
text = _implementer_prompt.build_new_issue_prompt(
cfg,
item,
group,
cached_result,
)
else:
return None
# Append the deterministic-sections stanzas the same way the
# full prefetch path does. The cached det_sections live on
# the item context (stashed during the original cycle's
# prefetch).
existing_context = item.get("_dispatcher_implementer_context") or {}
det_sections = existing_context.get("_deterministic_sections", {})
text = _append_deterministic_stanzas(
text,
cfg,
item,
group,
cached_result,
cached_clone,
det_sections=det_sections,
)
return text
def _prefetch_prompt(cfg: Any, item: dict[str, Any], group: Any) -> str:
"""Run the appropriate per-work-group fetcher and assemble the
rich prompt. Stamps ``item["_dispatcher_implementer_context"]``
so a future ``post_session_action`` (Phase 5b operator-status
comments) can read the same context without re-fetching.
Falls back to the legacy prompt ONLY in dry-run mode (for unit
tests) — otherwise the dispatcher must produce a prefetch-shaped
prompt every cycle when the flag is enabled. We do NOT silently
degrade to the legacy prompt on fetch failure: that produces an
underspecified prompt the worker will paper over.
The clone handle is stamped onto the item alongside the prefetch
result so the dispatcher's finally block can clean it up.
"""
pr_number = int(item.get("number") or 0)
# B3 (2026-05-13): when this is a re-entry from the escalation
# loop's ESCALATE branch (Tier N → N+1), the prefetch result and
# det_sections are already on the item context — re-running the
# entire prefetch + gate-preflight + compliance scan would burn
# ~7 minutes per tier transition on top of comment-cache wins
# from C5. Cached path: when ``_dispatcher_implementer_context``
# already has a populated ``result`` AND the worktree is still
# at the original head_sha (worktree-reset between tiers makes
# this invariant hold), just rebuild the prompt text from the
# cached pieces and skip the I/O.
existing_context = item.get("_dispatcher_implementer_context")
if isinstance(existing_context, dict) and existing_context.get("result"):
cached_result = existing_context["result"]
cached_clone = existing_context.get("clone_handle")
cached_text = _rebuild_prompt_from_cached_result(
cfg,
item,
group,
cached_result,
cached_clone,
)
if cached_text is not None:
_logger.info(
"prefetch cache hit for PR #%s — reused prefetch result + "
"det_sections from prior attempt (B3)",
pr_number,
)
return cached_text
# Per-cycle worker-infra seed: copy ``tools/local_ci_gate.sh`` and
# the implementer-side helper scripts into ``/tmp/local_tools/``
# (defaults; see ``_worker_infra_seed.py``). The worker invokes
# them via absolute ``/tmp/local_tools/...`` paths because the
# fork's master branch does NOT carry these files — keeping them
# in a separate /tmp tree means the worker never accidentally
# ``git add``s them into the PR. Seeding is best-effort: a copy
# failure logs WARNING and the worker falls back to its own
# diagnostic path (cwd doesn't contain the script → exit 2 from
# ``local_ci_gate.sh``, reported verbatim in the attempt comment).
# Gated by ``WORKER_INFRA_DISABLE=1`` for tests; the autouse
# pytest fixture sets that env var so unit tests don't write
# to ``/tmp/local_tools`` on the developer's box.
if not cfg.dry_run:
try:
result_seed = _worker_infra_seed.seed_worker_infra()
if not result_seed.skipped:
_logger.info(
"worker-infra seed: copied %d file(s) (%d bytes) from %s to %s",
result_seed.files_copied,
result_seed.bytes_copied,
result_seed.source_root,
result_seed.dest_dir,
)
except Exception as e:
# Best-effort: a seed failure should not crash the
# dispatcher; the worker will report the missing-script
# error verbatim on its first invocation attempt.
_logger.warning(
"worker-infra seed failed (worker will fall back to "
"cwd-relative invocation, which exits 2 on a fresh "
"clone): %s",
e,
)
if group.name in ("failing_ci_pr",):
result = _implementer_prefetch.fetch_pr_fix_context(cfg, item)
clone_section, clone_handle = _build_clone_section(
cfg,
pr_number,
result.head_sha,
head_ref=_head_ref_from_result(result),
)
text = _implementer_prompt.build_pr_fix_prompt(
cfg, item, group, result, clone_section
)
elif group.name in ("request_changes_pr",):
result = _implementer_prefetch.fetch_request_changes_context(cfg, item)
clone_section, clone_handle = _build_clone_section(
cfg,
pr_number,
result.head_sha,
head_ref=_head_ref_from_result(result),
)
text = _implementer_prompt.build_request_changes_prompt(
cfg, item, group, result, clone_section
)
elif group.name in ("new_issue",):
result = _implementer_prefetch.fetch_new_issue_context(cfg, item)
# No PR exists yet — no clone, no head_sha.
clone_handle = None
text = _implementer_prompt.build_new_issue_prompt(cfg, item, group, result)
else:
# Unknown group — preserve safety by falling back to legacy.
_logger.warning(
"_prefetch_prompt: unknown group=%s; falling back to legacy prompt",
group.name,
)
return _legacy_implementation_prompt(cfg, item, group)
item["_dispatcher_implementer_context"] = {
"result": result,
"clone_handle": clone_handle,
}
# PR-context sentinel: write a parallel on-disk handoff so a
# downstream subagent (typically ``task-implementor`` at depth 3
# of the ``task`` tool chain) can re-read the prefetched API data
# via ``tools/implementer_pr_context.py`` instead of either
# (a) hoping the prompt sections survived the intermediate tier
# agents' summarisation, or (b) re-issuing the Forgejo curls.
# Best-effort: a write failure logs WARNING and the worker falls
# back to the prompt content (or to curl).
#
# Dry-run gate: dry-run is the operator-visible "preview, no I/O"
# contract. The fetchers themselves already short-circuit in
# dry-run and return empty results — writing a sentinel for an
# empty result would (a) violate the no-I/O invariant and (b)
# leave a stale empty-shaped sentinel on disk that the next real
# cycle would have to overwrite. The workspace handoff is
# gated upstream in ``_build_clone_section``; this is the matching
# gate for the PR-context handoff.
# Compute deterministic sections (gate-preflight + compliance
# gaps) BEFORE the sentinel write so they land in the same
# payload the worker reads via ``implementer_pr_context.py``.
# ``_compute_deterministic_sections`` returns a dict keyed by
# ``compliance_gaps`` / ``gate_preflight`` (each value either a
# plain dict or ``None``). Skipped in dry-run / when no worktree
# was materialised.
det_sections = _compute_deterministic_sections(
cfg,
item,
group,
result,
clone_handle,
)
# Stash on the item context so the short-circuit logic
# (``_maybe_short_circuit``) can read the deterministic-section
# results without re-running compliance / preflight scans.
context = item.get("_dispatcher_implementer_context")
if isinstance(context, dict):
context["_deterministic_sections"] = det_sections
# G1 harvest (2026-05-15): record the metadata-only
# classification on the context so cycle telemetry and any
# future grooming-diversion path can observe how often the
# heuristic fires on real PRs. No behaviour change here —
# the dispatcher still spawns the worker for every cycle
# regardless of this flag. Actual diversion is deferred
# until either a dedicated grooming driver consumes
# ``tools/groom_label_inference``'s rule engine or the
# implementer-worker prompt is updated to take a no-clone
# fast path on the sentinel.
try:
context["metadata_only_candidate"] = _classify_metadata_only(
item,
result,
)
except Exception as exc: # noqa: BLE001 — best-effort observability
_logger.warning(
"metadata-only classification raised for #%s: %s",
pr_number,
exc,
)
context["metadata_only_candidate"] = False
# W8 harvest (2026-05-15): translate the prefetch carrier's
# per-section completion flags into a single
# ``{degraded, missing_sections, error_kinds}`` signal and
# stash it on the context so downstream telemetry / status
# comments can surface it. When
# ``IMPLEMENTER_DEGRADED_PROMPT_LOG_ENABLED=1`` (default OFF)
# AND the cycle is degraded, also emit a WARN log naming
# the specific missing sections so an operator tailing the
# dispatcher log can see the silent-degradation signal
# without parsing JSONL telemetry.
try:
completeness = _implementer_prefetch.assess_prompt_completeness(
result,
)
context["prompt_completeness"] = completeness
if completeness["degraded"] and _is_degraded_prompt_log_enabled():
_logger.warning(
"prompt assembly DEGRADED for #%s — missing "
"sections=%s; error_kinds=%s. Cycle will proceed "
"(crash-safety) but the worker may emit a "
"lower-quality verdict on partial context.",
pr_number,
completeness["missing_sections"],
completeness["error_kinds"],
)
except Exception as exc: # noqa: BLE001 — best-effort observability
_logger.warning(
"prompt completeness assessment raised for #%s: %s",
pr_number,
exc,
)
# C4: look up any recent dispatcher push for THIS head_sha so
# we can warn the worker not to re-do the same fix.
recent_push = None
head_sha = getattr(result, "head_sha", "") or ""
if head_sha:
try:
recent_push = _recent_push_cache.lookup_recent_push(
pr_number,
head_sha,
)
except Exception as exc:
_logger.warning(
"recent-push cache lookup raised for #%s: %s",
pr_number,
exc,
)
if not cfg.dry_run:
try:
_pr_context_sentinel.write(
pr_number=pr_number,
work_type=_work_type_for_group(group.name),
work_group=group.name,
result=result,
item=item,
compliance_gaps=det_sections.get("compliance_gaps"),
gate_preflight=det_sections.get("gate_preflight"),
recent_implementer_push=recent_push,
)
except Exception as e: # noqa: BLE001 — best-effort
_logger.warning(
"PR context sentinel write failed for PR #%s: %s",
pr_number,
e,
)
# Append a brief sentinel-pointer stanza + (when the flags are
# on) a short summary the agent can react to even if upstream
# tier agents summarise the prompt body. The authoritative copy
# lives in the sentinel; the prompt-side stanza is a fallback +
# nudge to read the sentinel.
text = _append_deterministic_stanzas(
text,
cfg,
item,
group,
result,
clone_handle,
det_sections=det_sections,
)
return text
def _compute_deterministic_sections(
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
clone_handle: Any,
) -> dict[str, Any]:
"""Run gate-preflight + compliance-gap detection and return a
dict with the two keys ``gate_preflight`` and ``compliance_gaps``
(each value is the section dict or absent).
Run-order rationale: gate-preflight first because the dispatcher
pays its wallclock cost (two ``--fast`` runs) regardless of what
compliance reports. Compliance is sub-100-ms and never blocks.
Skipped in dry-run / when no worktree was materialised — both
sections need the pre-cloned worktree. Returns an empty dict in
those cases (caller does not write either key into the sentinel).
"""
out: dict[str, Any] = {}
if cfg.dry_run:
return out
worktree = getattr(clone_handle, "path", None)
if not worktree:
return out
from pathlib import Path
worktree_path = Path(str(worktree))
if not worktree_path.exists():
return out
pr_number = int(item.get("number") or 0)
# ─── Gate pre-flight (off-by-default; runs --fast twice) ────
if _implementer_gate_preflight.is_preflight_enabled():
try:
changed_files = _collect_changed_files_from_result(result)
classification = _implementer_gate_preflight.run_preflight(
worktree_path,
changed_files,
)
if isinstance(classification, dict):
# P3: cross-check local preflight against the
# dispatcher's prefetched remote CI state. If the
# local --fast gates say "all clean" but remote CI
# is failing, the failing job is something --fast
# doesn't exercise (e.g. e2e_tests or coverage).
# Surface the divergence so the worker doesn't trust
# preflight alone — without this, the worker reads
# "preflight clean" + "compliance clean" and emits
# `resolved` while CI is still red (the 2026-05-13
# PR #30 attempt 1 / PR #28 cycle 2 failure mode).
remote_ci_state = _extract_remote_ci_state(result)
classification["remote_ci_state"] = remote_ci_state
preflight_clean = classification.get(
"failures_total", 0
) == 0 and not classification.get("preflight_timeout", False)
if preflight_clean and remote_ci_state == "failure":
classification["diverges_from_remote_ci"] = True
else:
classification["diverges_from_remote_ci"] = False
out["gate_preflight"] = classification
except Exception as exc:
_logger.warning(
"gate pre-flight failed for #%s (continuing without the section): %s",
pr_number,
exc,
)
# ─── Compliance gap detection ──────────────────────────────
# Two-flag gate: requires the escalation feature to be on
# (compliance is meaningless outside the escalation loop's
# gap-filling flow) AND the per-feature opt-out
# ``IMPLEMENTER_COMPLIANCE_GAPS_ENABLED`` to be non-falsy
# (default-ON). The split lets an operator disable just the
# compliance scan without losing tier escalation — useful
# when ``git`` is misbehaving on the pre-cloned worktree.
if _is_escalation_enabled() and _is_compliance_gaps_enabled():
try:
git_user_email = (
os.environ.get("GIT_USER_EMAIL")
or getattr(cfg, "git_user_email", "")
or ""
)
gaps, masked_checks = (
_implementer_compliance.check_compliance_gaps_with_masking(
worktree_path,
git_user_email,
)
)
if isinstance(gaps, dict):
out["compliance_gaps"] = {
"gaps": gaps,
"pr_number": pr_number or None,
"git_user_email": git_user_email,
# Masking-aware count: a downstream consumer
# reading just ``gaps_open_count`` on a
# fully-masked worktree would otherwise see 0
# (because masked checks return True) and read
# "all clean" on a tree the dispatcher couldn't
# actually inspect. Passing the masked set
# excludes those keys from the count entirely.
"gaps_open_count": _implementer_compliance.gaps_open_count(
gaps,
masked_checks=masked_checks,
),
# The masked-checks list lets the renderer hedge
# the "all passed" verdict when git itself failed
# on one or more checks. Sorted for deterministic
# serialisation in the sentinel.
"masked_checks": sorted(masked_checks),
}
except Exception as exc:
_logger.warning(
"compliance gap detection failed for #%s "
"(continuing without the section): %s",
pr_number,
exc,
)
return out
def _append_deterministic_stanzas(
prompt: str,
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
clone_handle: Any,
*,
det_sections: dict[str, Any] | None = None,
) -> str:
"""Append a brief sentinel-pointer stanza (plus, when the
matching flag is on, a condensed inline summary) for each
deterministic section the dispatcher computed.
Why a short stanza vs. the full markdown
----------------------------------------
The previous design embedded the full classification + per-gap
hints in the prompt body. ``task-implementor.md:305`` is explicit
that intermediate tier agents routinely summarise non-diff
sections away before the worker sees them. The sentinel survives
because the worker reads it directly off disk.
This stanza is intentionally short (one paragraph + one bullet
list per section) so its survival probability through tier-agent
summarisation is high. It tells the agent: (a) the read command,
(b) the headline counts, (c) the per-gap hints. The full
classification stays in the sentinel where the agent can drill in.
Order: gate-preflight stanza first (it tells the agent which
test failures matter), then compliance stanza (it tells the
agent what to fill in). When compliance reports "all gaps
closed" the agent's exit path is clean even if gate-preflight
surfaced unrelated flakes.
"""
if cfg.dry_run:
return prompt
worktree = getattr(clone_handle, "path", None)
if not worktree:
return prompt
from pathlib import Path
worktree_path = Path(str(worktree))
if not worktree_path.exists():
return prompt
# When the caller did not pre-compute, do it here so legacy
# callers (and test fixtures that hand-roll a clone_handle)
# still produce a fully-populated stanza. Production callers
# in :func:`_prefetch_prompt` pass ``det_sections`` so the same
# computation runs once per cycle and lands in BOTH the sentinel
# and the prompt — no double work. An earlier iteration emitted
# a WARNING on this path to catch production-callsite regression;
# removed because (a) the sole production caller is one line of
# code, easily audited, and (b) the noise leaked into tests and
# broke ``pytest -W error`` setups.
if det_sections is None:
det_sections = _compute_deterministic_sections(
cfg,
item,
group,
result,
clone_handle,
)
pr_number = int(item.get("number") or 0)
extras: list[str] = []
# ─── Gate pre-flight summary (always before compliance) ─────
# ``_compute_deterministic_sections`` only sets ``gate_preflight``
# when ``is_preflight_enabled()`` is True; ``run_preflight`` then
# always returns at least ``{"preflight_enabled": True, ...}``
# so the dict-shape check alone is sufficient. (An earlier
# iteration OR'd over three truthy fields as defensive coverage
# against an unreachable caller — removed for clarity.)
preflight = det_sections.get("gate_preflight")
if isinstance(preflight, dict):
extras.append(_render_preflight_pointer_stanza(preflight, pr_number))
# ─── Compliance summary (always after gate-preflight) ───────
# P8 fix (2026-05-13): the compliance renderer needs the
# preflight payload to decide whether "all compliance gaps
# closed" is sufficient grounds to direct emit-resolved. When
# preflight shows related-to-diff failures, divergence from
# remote CI, or its own timeout, the green-path directive is
# actively misleading — every run-2 disputed-resolved attempt
# hit this. ``preflight`` is None when the section wasn't
# computed this cycle (flag off / dry-run); the renderer treats
# that as "no preflight signal to block on" and behaves exactly
# as before.
compliance = det_sections.get("compliance_gaps")
if isinstance(compliance, dict) and isinstance(compliance.get("gaps"), dict):
masked_checks = compliance.get("masked_checks") or []
extras.append(
_render_compliance_pointer_stanza(
compliance["gaps"],
pr_number,
masked_checks=list(masked_checks),
preflight=preflight if isinstance(preflight, dict) else None,
)
)
if not extras:
return prompt
return prompt + "\n\n" + "\n\n".join(extras) + "\n"
def _render_preflight_pointer_stanza(
preflight: dict[str, Any],
pr_number: int,
) -> str:
"""Brief gate-preflight summary + sentinel-read hint.
Surfaces preflight-timeout explicitly: the underlying
:func:`_implementer_gate_preflight._run_gate_once` returns
``preflight_timeout=True`` on subprocess.TimeoutExpired. Without
surfacing it here, the renderer would emit a clean "no failures"
summary on a silent timeout (the previous bug).
Defensive short-circuit: ``preflight_enabled=False`` is the
documented disabled-shape from :func:`run_preflight`. Production
never lands here (``_compute_deterministic_sections`` already
gates on ``is_preflight_enabled()`` before computing), but a
future caller writing the disabled-shape into the sentinel
would otherwise trigger the misleading "no persistent failures"
green-light line. Treat disabled-shape as "no stanza."
"""
if not preflight.get("preflight_enabled"):
return ""
statuses = preflight.get("gate_statuses") or {}
failures_total = int(preflight.get("failures_total") or 0)
related = preflight.get("related") or []
unrelated = preflight.get("unrelated") or []
timeout = bool(preflight.get("preflight_timeout"))
lines = ["## Pre-flight gate summary (read sentinel for detail)"]
lines.append("")
if timeout:
# Timeout payloads zero the counts in the orchestrator so
# the renderer can't double-message; this stanza tells the
# worker the classification is suppressed and why.
lines.append(
"**Pre-flight timed out** — the dispatcher could not "
"deterministically classify failures this cycle. Treat "
"every gate failure you see in-session as potentially real."
)
lines.append(
"_Classification suppressed: persistent-failure counts "
"are not reported on timeout._"
)
if statuses:
roll = ", ".join(f"`{g}`={s}" for g, s in sorted(statuses.items()))
lines.append(f"Gate roll-up: {roll}")
if failures_total and not timeout:
# Only emit the count outside the timeout path. The
# timeout branch suppresses counts on purpose; this guard
# is a belt-and-braces defence against a future caller
# bypassing the orchestrator's zeroing.
lines.append(
f"Persistent failures: **{failures_total}** "
f"(related-to-diff={len(related)}, unrelated={len(unrelated)})."
)
elif not timeout:
lines.append(
"No persistent failures across two pre-flight runs — you "
"do NOT need to re-run `local_ci_gate.sh --fast` unless "
"you change files."
)
# P8/P3 surface (2026-05-13): when local preflight passes but
# remote CI says "failure", the failing job is something --fast
# doesn't run (e2e_tests, coverage_report). Without this line
# the worker reads "preflight clean" and (combined with a clean
# compliance check) emits resolved — every run-2 cycle hit this.
# The sentinel always carried ``diverges_from_remote_ci`` since
# P3 landed; this just surfaces it in the prose so the worker
# sees it without having to read the sentinel.
if preflight.get("diverges_from_remote_ci") and not timeout:
lines.append(
"**Pre-flight diverges from remote CI** "
"(`diverges_from_remote_ci=true`): local `--fast` gates "
"pass but the PR's remote CI is failing. The failing "
"check is something `--fast` does not exercise (likely "
"`e2e_tests` or `coverage_report`). Read "
f"`--field ci` to identify the failing check and address "
'it. **Do NOT emit `{"outcome": "resolved"}` until '
"remote CI is green.**"
)
elif (
preflight.get("remote_ci_state") == "failure" and failures_total and not timeout
):
# Both local and remote are red — note the remote state so
# the worker isn't tempted to treat the unrelated locals as
# the whole picture.
lines.append(
"Remote CI is also failing — check `--field ci` for "
"which specific checks remote reports as failed before "
"deciding whether your local-related failures are the "
"whole story."
)
lines.append(
"Full classification (per-scenario paths + run metadata) is "
"in the PR-context sentinel: "
f"`python3 tools/implementer_pr_context.py read --pr {pr_number} "
"--field gate_preflight`"
)
return "\n".join(lines)
def _render_compliance_pointer_stanza(
gaps: dict[str, bool],
pr_number: int,
*,
masked_checks: list[str] | None = None,
preflight: dict[str, Any] | None = None,
) -> str:
"""Brief compliance summary + sentinel-read hint.
Mirrors :func:`_implementer_compliance.render_prompt_stanza` but
condensed: this is the SHORT version that survives tier-agent
summarisation. The full per-gap hints are still rendered by the
compliance module's own helper if the worker chooses to fetch
them.
``masked_checks`` carries the list of check names where the
underlying git call failed and the function fell back to the
"treat as passing" policy. When non-empty the renderer
explicitly downgrades the "all checks passed" verdict to "all
OBSERVABLE checks passed; N were masked because git failed"
and refuses to direct the worker to exit. Without this hedge
the worker would receive a confident `resolved` directive on
a worktree the dispatcher couldn't actually inspect — see
plan v4 §"Compliance masking footgun".
``preflight`` is the optional gate-preflight section dict (same
shape as ``run_preflight`` returns). When provided, the renderer
consults it to decide whether the green-path "emit resolved"
directive is safe — if preflight shows related-to-diff failures,
divergence from remote CI, or its own timeout, the directive is
SUPPRESSED and replaced with an explicit "do not emit resolved
until preflight is clean" line. This is the P8 fix (2026-05-13):
every run-2 disputed-resolved attempt was caused by emitting the
green directive while preflight was reporting failure. ``None``
(the default) preserves pre-fix behaviour for callers that don't
have a preflight payload (legacy fixtures, dispatcher cycles
where preflight didn't run).
"""
masked_checks = masked_checks or []
all_closed = _implementer_compliance.all_gaps_closed(gaps)
missing = [k for k, v in gaps.items() if not v]
# P8: derive the "preflight blocks emit-resolved" predicate.
# When preflight is absent / disabled / not provided, the
# predicate is False and the renderer behaves as before.
preflight_block_reasons = _compliance_preflight_block_reasons(preflight)
lines = ["## Compliance gap report (read sentinel for hints)"]
lines.append("")
if all_closed and not masked_checks and not preflight_block_reasons:
lines.append(
"**All compliance checks passed.** The PR is complete — "
"do NOT re-apply or re-edit the code fix. Verify quality "
'gates and emit `{"outcome": "resolved", "files_touched": []}`.'
)
elif all_closed and not masked_checks and preflight_block_reasons:
# P8 fix branch: compliance is clean but preflight has
# evidence the PR is NOT actually done. Spell out exactly
# what's wrong so the worker can act on it. The wording
# explicitly forbids emit-resolved and explains the cost
# of emitting it anyway (the dispatcher will catch the
# lie via ``outcome_disputed=True`` and escalate the tier).
joined = "; also ".join(preflight_block_reasons)
lines.append("**Compliance items are complete, but " + joined + ".**")
lines.append(
'Do NOT emit `{"outcome": "resolved"}` yet — fix the '
"underlying failure(s), verify the quality gates pass, "
"then commit and push the real fix. Emitting `resolved` "
"without advancing HEAD will be detected by the "
"dispatcher as a disputed-resolved and the work will be "
"escalated to the next tier (wasting your model's "
"budget)."
)
elif all_closed and masked_checks:
# Hedge: every check that COULD be evaluated passed, but
# one or more underlying ``git`` calls failed so the
# dispatcher could not actually inspect those signals.
# Do NOT direct the worker to exit — the masked check
# could be hiding a real gap.
joined_masked = ", ".join(f"`{m}`" for m in masked_checks)
lines.append(
"**All OBSERVABLE compliance checks passed, but "
f"{len(masked_checks)} check(s) were MASKED** because "
f"`git` itself failed: {joined_masked}. Do NOT exit "
"with `resolved` — re-verify the masked check(s) "
"in-session (run `git status` / `git log -1`) before "
"deciding whether the PR is actually complete."
)
else:
joined = ", ".join(f"`{m}`" for m in missing)
lines.append(f"Gaps to fill ({len(missing)}): {joined}.")
lines.append(
"Fill ONLY the missing items — the existing code fix in "
"HEAD is correct and should not be re-touched unless a "
"related-to-diff gate failure tells you otherwise."
)
if masked_checks:
joined_masked = ", ".join(f"`{m}`" for m in masked_checks)
lines.append(
f"_Note: {len(masked_checks)} check(s) were masked "
f"because git failed: {joined_masked}. The values "
"shown for those keys may be stale — re-verify in-session._"
)
lines.append(
"Per-gap hints + ground-truth dict are in the PR-context "
f"sentinel: `python3 tools/implementer_pr_context.py read "
f"--pr {pr_number} --field compliance_gaps`"
)
return "\n".join(lines)
def _compliance_preflight_block_reasons(
preflight: dict[str, Any] | None,
) -> list[str]:
"""Return a list of human-readable reasons preflight blocks the
"emit resolved" directive, or empty when nothing blocks.
P8 fix (2026-05-13). Used by
:func:`_render_compliance_pointer_stanza` to decide whether the
green-path verdict is safe. Three independent block conditions
are returned as separate strings so the renderer can join them
with semicolons; each is phrased to continue a sentence that
starts with "Compliance items are complete, but …".
Block conditions:
- **related-to-diff preflight failures** — the local gates show
failures whose paths intersect the PR's diff. Highest-confidence
signal that HEAD is not actually fixed.
- **diverges_from_remote_ci** — local preflight passes but
remote CI reports failure. The failing check is something
``--fast`` doesn't exercise (e2e_tests, coverage_report).
- **preflight_timeout** — the dispatcher couldn't classify
anything because the gate run timed out; local state is
unverified.
``None`` / ``preflight_enabled=False`` / empty dict all return
an empty list (no signal to act on).
"""
if not isinstance(preflight, dict):
return []
if not preflight.get("preflight_enabled"):
return []
reasons: list[str] = []
if preflight.get("preflight_timeout"):
reasons.append(
"pre-flight timed out (`preflight_timeout=true`) — local "
"gate state is unverified this cycle. Re-run "
"`bash /tmp/local_tools/tools/local_ci_gate.sh --fast "
"--repo-root {repo_dir}` in-session before deciding"
)
# When preflight timed out the related/divergence signals
# are zeroed by the orchestrator — don't double-message.
return reasons
related = preflight.get("related") or []
if related:
reasons.append(
f"pre-flight reports **{len(related)}** failure(s) "
"**related to this diff** — the code in HEAD is not "
"actually passing the gates (look at `--field gate_preflight` "
"for the per-scenario paths)"
)
if preflight.get("diverges_from_remote_ci"):
reasons.append(
"**local pre-flight passes but remote CI is failing** "
"(`diverges_from_remote_ci=true`) — the failing job is "
"something `--fast` does not exercise (`e2e_tests` or "
"`coverage_report`). Fetch the failing check from "
"`--field ci` and address it"
)
return reasons
def _extract_remote_ci_state(result: Any) -> str:
"""Pull the remote CI aggregate state from the prefetch result.
The dispatcher's :mod:`_implementer_prefetch` fetches the
PR's HEAD CI status; this just normalises the value into one
of ``"success"``, ``"failure"``, ``"pending"``, or ``"unknown"``.
Used by :func:`_compute_deterministic_sections` (P3) to
cross-check the local preflight result against what remote CI
actually says.
"""
ci_status = getattr(result, "ci_status", None)
if isinstance(ci_status, dict):
state = ci_status.get("state")
if isinstance(state, str) and state:
return state.lower()
if isinstance(ci_status, str) and ci_status:
return ci_status.lower()
return "unknown"
def _collect_changed_files_from_result(result: Any) -> list[str]:
"""Best-effort extraction of the PR's changed-file list from the
prefetch result. Used by the gate pre-flight to classify gate
failures as related/unrelated to the diff.
The :mod:`_implementer_prefetch` result carries diff bytes; we
parse the standard ``diff --git a/path b/path`` headers to
enumerate file paths. Returns an empty list when no diff is
available — the classifier then treats every failure as
"unrelated", which is the safe direction (false negatives don't
cause regressions, they just over-report unrelated failures).
"""
diff_text = ""
for attr in ("diff", "diff_text", "unified_diff", "pr_diff"):
v = getattr(result, attr, None)
if isinstance(v, str) and v:
diff_text = v
break
if isinstance(v, bytes):
diff_text = v.decode("utf-8", errors="replace")
break
if not diff_text:
return []
import re as _re
paths: list[str] = []
seen: set[str] = set()
# Match ``diff --git a/<path> b/<path>`` lines
for m in _re.finditer(
r"^diff --git a/(\S+) b/\S+",
diff_text,
_re.MULTILINE,
):
p = m.group(1)
if p not in seen:
seen.add(p)
paths.append(p)
return paths
# C2 harvest (2026-05-15): the classifier itself lives in
# :mod:`_implementer_metadata_classifier` to start the long-overdue
# decomposition of this 3.2k-line driver toward the ~500-line module
# budget the rest of ``tools/`` honours. The leading-underscore alias
# below preserves the in-module name so existing tests continue to
# call ``driver._classify_metadata_only(...)`` without churn.
_classify_metadata_only = _implementer_metadata_classifier.classify_metadata_only
def _maybe_short_circuit(
cfg: Any,
item: dict[str, Any],
context: dict[str, Any],
) -> None:
"""Decide whether the dispatcher can satisfy this PR's cycle
without spawning the LLM worker, and if so stash a synthetic
``SessionResult`` on ``context["_short_circuit_result"]`` for
the dispatch runtime to consume.
Two short-circuit paths:
1. **P0 — skip-when-green.** If compliance scan says all checks
passed, gate-preflight says no failures, AND remote CI status
is ``success``, then this PR is genuinely done and the
listing script's "failing CI" snapshot was stale (CI flipped
between snapshot and claim). Emit ``no_changes_needed`` and
release. Fires whenever escalation is on and conditions hold.
2. **A — auto-fix-compliance.** If compliance scan reports gaps
that the dispatcher can fix deterministically (CONTRIBUTORS,
CHANGELOG bullet, ISSUES CLOSED footer), gate-preflight is
clean, AND remote CI failure looks attributable only to those
missing items, then apply the fixes + push + emit
``resolved`` synthetically. Gated by
``IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE=1`` because it
gives the dispatcher commit/push authority.
Both paths are no-ops when:
- dry-run
- escalation feature disabled
- the item isn't PR-shaped
- the prefetch result is missing
- any required signal (compliance, preflight, CI) is absent or
ambiguous
"""
if cfg.dry_run:
return
if not _is_escalation_enabled():
return
if not isinstance(item.get("head"), dict):
return
if not isinstance(context, dict):
return
result = context.get("result")
if result is None:
return
clone_handle = context.get("clone_handle")
if clone_handle is None or not getattr(clone_handle, "path", None):
return
pr_number = int(item.get("number") or 0)
remote_ci_state = _extract_remote_ci_state(result)
# Pull the deterministic sections we already computed for the
# sentinel. Re-running them would double the compute cost; both
# are stashed on the context by ``_prefetch_prompt``.
det_sections = context.get("_deterministic_sections") or {}
compliance = det_sections.get("compliance_gaps") or {}
preflight = det_sections.get("gate_preflight") or {}
gaps: dict[str, bool] = compliance.get("gaps") or {}
masked_checks = compliance.get("masked_checks") or []
if not gaps:
# No compliance scan ran — be conservative and dispatch
# the worker. Most commonly this means the preclone was
# disabled or failed.
return
preflight_clean = bool(
preflight
and preflight.get("failures_total", 0) == 0
and not preflight.get("preflight_timeout", False)
)
compliance_clean = all(bool(v) for v in gaps.values()) and not masked_checks
# ─── P0: everything green ──────────────────────────────────
if compliance_clean and preflight_clean and remote_ci_state == "success":
_logger.info(
"P0 short-circuit for PR #%s: compliance clean + preflight "
"clean + remote CI success — skipping LLM worker",
pr_number,
)
context["_short_circuit_result"] = {
"status": "completed",
"wallclock_seconds": 0.0,
"session_id": "",
"raw_response": (
'{"outcome": "no_changes_needed", "files_touched": [], '
'"_dispatcher_short_circuit": "P0"}'
),
"parsed_json": {
"outcome": "no_changes_needed",
"files_touched": [],
"_dispatcher_short_circuit": "P0",
},
}
return
# ─── A: auto-fix compliance gaps ───────────────────────────
# Only fires when (a) the operator opted in via the flag, (b)
# preflight is clean (so failing CI is plausibly about
# compliance-shaped checks, not test failures), (c) compliance
# has at least one gap, (d) none of the gaps are masked (we
# don't have reliable signal to fix what we can't see).
if (
not _is_auto_fix_compliance_enabled()
or masked_checks
or compliance_clean
or not preflight_clean
):
return
# Don't auto-fix when remote CI is passing — there's nothing to
# fix in that case (P0 above would have caught the truly green
# case; this guards against weird-state cycles where CI is
# green but the dispatcher's compliance scan found something).
if remote_ci_state == "success":
return
worktree = Path(str(clone_handle.path))
pr_title = ""
pr_details = getattr(result, "pr_details", None)
if isinstance(pr_details, dict):
pr_title = str(pr_details.get("title") or "")
if not pr_title:
pr_title = str(item.get("title") or f"PR #{pr_number}")
linked = getattr(result, "linked_issues", None) or []
linked_issue_numbers: list[int] = []
for li in linked:
if isinstance(li, dict):
n = li.get("number")
if isinstance(n, int) and n > 0:
linked_issue_numbers.append(n)
git_user_name = (
os.environ.get("GIT_USER_NAME")
or getattr(cfg, "git_user_name", "")
or "CleverThis"
)
git_user_email = (
os.environ.get("GIT_USER_EMAIL")
or getattr(cfg, "git_user_email", "")
or "hal9000@cleverthis.com"
)
branch = ""
if isinstance(pr_details, dict):
head = pr_details.get("head") or {}
if isinstance(head, dict):
branch = str(head.get("ref") or "")
if not branch:
_logger.warning(
"auto-fix short-circuit for PR #%s aborted: missing head_ref "
"(can't push without the branch name); falling through to worker",
pr_number,
)
return
try:
report = _implementer_compliance_apply.apply_compliance_fixes(
worktree,
gaps,
git_user_name=git_user_name,
git_user_email=git_user_email,
pr_title=pr_title,
pr_number=pr_number,
linked_issue_numbers=linked_issue_numbers,
)
except Exception as exc:
_logger.warning(
"auto-fix apply raised for PR #%s; falling through to worker: %s",
pr_number,
exc,
)
return
if not report.all_applied:
_logger.info(
"auto-fix could not deterministically resolve all gaps for PR "
"#%s (%d failures); falling through to worker",
pr_number,
sum(1 for r in report.per_gap if r.error),
)
return
if not report.any_committed:
# All gaps were already satisfied (idempotent re-run). Push
# is unnecessary — the worktree is the same as origin.
_logger.info(
"auto-fix found no actual gaps to fix on PR #%s after recheck "
"(idempotent); short-circuit emits no_changes_needed",
pr_number,
)
context["_short_circuit_result"] = {
"status": "completed",
"raw_response": (
'{"outcome": "no_changes_needed", "files_touched": [], '
'"_dispatcher_short_circuit": "A_idempotent"}'
),
"parsed_json": {
"outcome": "no_changes_needed",
"files_touched": [],
"_dispatcher_short_circuit": "A_idempotent",
},
}
return
# Log the apply phase BEFORE the push so a subsequent push failure
# still leaves a trace of what the deterministic applier did.
_logger.info(
"auto-fix for PR #%s applied %d compliance fix(es), committed %s"
"pushing to branch %s",
pr_number,
sum(1 for r in report.per_gap if r.applied and not r.error),
report.final_head_sha[:12] if report.final_head_sha else "<unknown>",
branch,
)
# Push the deterministic commit. The pre-cloned worktree's ``origin``
# is a bare HTTPS URL with no embedded credentials (see
# ``_pr_clone._clone_url``), so the push MUST run under the
# credential-bearing git env — the GIT_ASKPASS shim that supplies
# the Forgejo PAT, identical to the env the pre-clone/fetch already
# use. Without it the push fails rc=128 "could not read Username"
# and the auto-fix silently falls through to the worker.
push_result = _implementer_compliance_apply.push_branch(
worktree,
branch,
git_env=_pr_clone._git_env(cfg),
)
if not push_result.applied:
_logger.warning(
"auto-fix push failed for PR #%s (%s); falling through to worker",
pr_number,
push_result.error,
)
return
_logger.info(
"auto-fix short-circuit for PR #%s: applied %d compliance fix(es) + pushed %s",
pr_number,
sum(1 for r in report.per_gap if r.applied and not r.error),
report.final_head_sha[:12] if report.final_head_sha else "<unknown>",
)
context["_short_circuit_result"] = {
"status": "completed",
"raw_response": (
'{"outcome": "resolved", "files_touched": ["CHANGELOG.md", '
'"CONTRIBUTORS.md"], "_dispatcher_short_circuit": "A_auto_fix", '
'"_auto_fix_head_sha": "' + report.final_head_sha + '"}'
),
"parsed_json": {
"outcome": "resolved",
"files_touched": [
r.gap_name for r in report.per_gap if r.applied and not r.error
],
"_dispatcher_short_circuit": "A_auto_fix",
"_auto_fix_head_sha": report.final_head_sha,
},
}
# Stash the apply report for downstream telemetry / cycle archive
# consumers that want the audit trail.
context["_auto_fix_report"] = {
"all_applied": report.all_applied,
"any_committed": report.any_committed,
"final_head_sha": report.final_head_sha,
"per_gap": [
{
"gap_name": r.gap_name,
"applied": r.applied,
"error": r.error,
}
for r in report.per_gap
],
}
def _tier_to_task_implementor_agent() -> dict[int, str]:
"""Tier-integer → ``task-implementor-tier-<slot>`` variant name.
Loaded fresh on every call (cheap: a single yaml.safe_load) so a
manifest edit followed by the generator + OpenCode restart picks
up without restarting the dispatcher. The variant names match
the .md / .txt files emitted by
:mod:`sync_tier_models` and the agent block in ``opencode.json``.
"""
return {
e.tier: e.task_implementor_variant_name
for e in _sync_tier_models.load_manifest()
}
def _resolve_task_implementor_for_tier(tier: int) -> str:
"""Map an escalation-tier integer to the matching
``task-implementor-tier-<slot>`` agent name.
Falls back to the default-tier variant when the requested tier
is not in the manifest (defensive: a stale label or a
misconfigured caller cannot strand a dispatch cycle, the worker
just runs at the default model instead of failing to spawn).
"""
mapping = _tier_to_task_implementor_agent()
if tier in mapping:
return mapping[tier]
_logger.warning(
"tier %s not in manifest; falling back to default tier %s variant",
tier,
_DEFAULT_START_TIER,
)
return mapping[_DEFAULT_START_TIER]
def _call_python_estimator(
cfg: Any,
base_prompt: str,
*,
tag: str,
on_poll: Any | None = None,
redact_values: list[str] | None = None,
pr_number: int | None = None,
head_sha: str | None = None,
) -> int | None:
"""Run ``estimator-implementation`` as a top-level OpenCode session
from Python and return the resolved tier integer, or ``None`` if
the estimator was not confident / failed.
Replaces the (retired) ``tier-dispatcher`` → ``estimator-implementation``
subagent hop. The estimator's contract is unchanged: it consumes
a prompt body, returns ``{"is_confident": bool, "recommended_tier":
int}``. We invoke it directly via :func:`run_session_blocking`
instead of nesting it under a wrapper LLM session whose only
judgement was "should I call the estimator?" — a purely
deterministic question the Python dispatcher already answered
by the time we get here.
Caching:
When both ``pr_number`` and ``head_sha`` are provided, the
result is cached in-process keyed by ``(pr_number, head_sha)``
for ``IMPLEMENTER_ESTIMATOR_CACHE_TTL_S`` seconds (default
1 h). Subsequent cycles for the same PR + same commit reuse
the cached tier without re-spawning the LLM session. Both
confident and no-confidence outcomes are cached. A new
commit (different head_sha) implicitly invalidates the
cache entry. Pass ``pr_number=None`` to bypass the cache
(e.g. for non-PR items).
Heartbeat:
When ``on_poll`` is ``None`` and ``cfg.heartbeat_path`` is
set, the estimator session refreshes the dispatcher's
heartbeat file every poll iteration so a 30-180 s estimator
call cannot look "hung" to the launcher / systemd unit
watching the dispatcher process. The caller may pass an
explicit ``on_poll`` to override (e.g. tests that want to
verify no I/O).
Returns:
- The recommended tier integer when the estimator returned
``is_confident: true`` and a valid tier in the manifest's
range.
- ``None`` when the estimator was not confident, returned an
out-of-range tier, failed to emit parseable JSON, or
transport-errored / timed out. The caller defaults to
:data:`_DEFAULT_START_TIER` per the legacy
tier-dispatcher rule.
"""
cache_eligible = (
pr_number is not None and isinstance(head_sha, str) and bool(head_sha)
)
if cache_eligible:
hit, cached_tier = _estimator_cache_get(pr_number, head_sha)
if hit:
_logger.info(
"estimator cache hit for PR #%s @ %s: tier=%s",
pr_number,
head_sha[:12],
cached_tier,
)
return cached_tier
estimator_prompt = _wrap_for_estimator(base_prompt)
if on_poll is None:
on_poll = _estimator_heartbeat_callback(cfg)
if redact_values is None:
redact_values = _redact_values_for(cfg)
result = _opencode_worker.run_session_blocking(
server_url=cfg.server_url,
agent="estimator-implementation",
tag=f"{tag}-estimator",
prompt=estimator_prompt,
timeout_seconds=_ESTIMATOR_TIMEOUT_SECONDS,
on_poll=on_poll,
redact_values=redact_values,
)
def _record(t: int | None) -> int | None:
if cache_eligible:
_estimator_cache_put(pr_number, head_sha, t)
return t
if result.status != "completed":
_logger.warning(
"estimator session status=%s; falling back to default tier %s",
result.status,
_DEFAULT_START_TIER,
)
# Do NOT cache transport / timeout failures — those are
# environmental and we want to retry next cycle rather than
# serve stale "default tier 0" for an hour.
return None
parsed = result.parsed_json
if not isinstance(parsed, dict):
_logger.info(
"estimator emitted no parseable JSON; falling back to default tier"
)
return _record(None)
if not parsed.get("is_confident"):
_logger.info(
"estimator returned no-confidence; using default tier %s",
_DEFAULT_START_TIER,
)
return _record(None)
tier = parsed.get("recommended_tier")
if not isinstance(tier, int) or isinstance(tier, bool):
_logger.warning(
"estimator returned non-int recommended_tier=%r; ignoring",
tier,
)
return _record(None)
valid_tiers = set(_tier_to_task_implementor_agent().keys())
if tier not in valid_tiers:
_logger.warning(
"estimator recommended out-of-manifest tier=%s; ignoring",
tier,
)
return _record(None)
return _record(tier)
def _wrap_for_estimator(body: str) -> str:
"""Format ``body`` as the estimator's input prompt.
The estimator-implementation agent's prompt contract instructs
it to "Look for the following sections in your prompt" — e.g.
``## Pre-fetched PR description``, ``## Pre-fetched CI status``.
Those headers already live at the top level of ``body`` (the
dispatcher-built worker prompt), so the wrapper just prepends a
short directive and emits the body verbatim.
Pre-R3 the wrapper put the body inside a triple-backtick fence;
that turned the ``## Pre-fetched …`` headers into code-block
content which (a) some tokenizers stop treating as headers and
(b) the estimator's section-finder might miss. Post-R3 the body
is emitted at the top level so the estimator's existing logic
sees the sections exactly where it expects them.
"""
return (
"Evaluate the complexity of this task and recommend an "
"appropriate starting implementation tier. Indicate clearly "
"if you are confident in your determination. The prefetched "
"work-item sections appear below at the top level — read "
"them directly.\n"
"\n"
f"{body}"
)
def _emit_task_implementor_body(
body: str,
*,
escalation_tier: int,
) -> str:
"""Emit the prompt body for direct ``task-implementor-tier-N``
invocation (R3 cutover).
Replaces :func:`_wrap_for_tier_dispatcher` for the post-R3
direct-dispatch path. The Python dispatcher has already
resolved the tier, so there is no estimator/tier-dispatcher
wrapper to brief — the worker receives the body it would have
seen at depth-3 under the old chain, plus an ``escalation_tier:
N`` line so it can cite the tier in its attempt comment (matches
the contract tier-N forwarded to task-implementor before
retirement).
The ``release_claim_on_exit`` directive is intentionally OMITTED
(R2, 2026-05-16): the dispatcher's outer ``finally`` block has
always owned the actual claim lifecycle.
"""
return f"{body}\n\nescalation_tier: `{int(escalation_tier)}`\n"
def _implementation_prompt_dispatch(cfg: Any, item: dict[str, Any], group: Any) -> str:
"""Top-level prompt factory honouring two flag families:
- ``IMPLEMENTER_DISPATCHER_PREFETCH`` — ``=1`` produces the
rich pre-fetched prompt; anything else produces the legacy
title-only prompt.
- ``IMPLEMENTER_ESCALATION_ENABLED`` — when ``=1`` AND the item
is a PR (not a new_issue), the dispatcher reads
``auto/last-attempt-tier-N`` from Forgejo and seeds
``start_tier``. When ``start_tier > 0`` the dispatcher routes
straight to that tier's ``task-implementor-tier-<slot>``
variant — closing the cross-cycle resumption loop the
2026-05-12 review pointed at. The seeded tier is also stashed
on ``item["_dispatcher_implementer_context"]`` so
:func:`_post_session_action_with_escalation` knows where the
cycle started without re-fetching the labels.
Post-R3 (2026-05-17): the dispatcher resolves the tier in Python
here — calling the estimator directly when the estimator flag is
on and no explicit hint exists — and stashes the resolved
``task-implementor-tier-<slot>`` variant on the item context
under :data:`_WORKER_AGENT_OVERRIDE_KEY`. ``_dispatch_runtime``
reads that override at session-spawn time, bypassing the
(retired) ``tier-dispatcher`` and ``tier-N`` wrapper agents.
"""
if _is_prefetch_enabled():
base_prompt = _prefetch_prompt(cfg, item, group)
else:
base_prompt = _legacy_implementation_prompt(cfg, item, group)
estimator_enabled = _is_implementer_estimator_enabled()
is_pr_shape = isinstance(item.get("head"), dict)
# Always seed the per-item context so the worker-agent override
# key + tier propagate to the dispatch loop. ``_prefetch_prompt``
# already creates the context dict for prefetch-enabled cycles;
# this branch keeps the non-prefetch / non-PR paths consistent.
context = item.get("_dispatcher_implementer_context")
if not isinstance(context, dict):
context = {}
item["_dispatcher_implementer_context"] = context
# Branch A: in-cycle escalation OFF or non-PR item — no
# cross-cycle label history to read, no per-cycle escalation
# loop to seed. Resolve the start tier from the estimator
# (if enabled) or default to tier 0. Dry-run skips the
# estimator call entirely so a ``--dry-run`` cycle does not
# spend an LLM round-trip on a no-op dispatch.
if not _is_escalation_enabled() or not is_pr_shape:
if estimator_enabled and not getattr(cfg, "dry_run", False):
tier = _call_python_estimator(
cfg,
base_prompt,
tag=_estimator_tag_for(group, item),
on_poll=None,
redact_values=_redact_values_for(cfg),
pr_number=_estimator_cache_pr_key(item),
head_sha=_estimator_cache_head_sha(item),
)
if tier is None:
tier = _DEFAULT_START_TIER
else:
tier = _DEFAULT_START_TIER
context["start_tier"] = tier
context[_WORKER_AGENT_OVERRIDE_KEY] = _resolve_task_implementor_for_tier(tier)
item[_WORKER_AGENT_OVERRIDE_KEY] = context[_WORKER_AGENT_OVERRIDE_KEY]
return _emit_task_implementor_body(base_prompt, escalation_tier=tier)
# Branch B: in-cycle escalation ON and PR-shaped item.
pr_number = int(item.get("number") or 0)
start_tier = _read_start_tier_from_labels(cfg, pr_number)
context["start_tier"] = start_tier
# Resolve the START tier for this cycle. Priority chain mirrors
# the legacy ``tier-dispatcher`` rules:
# - start_tier > 0 → label-driven cross-cycle resumption tier
# (the auto/last-attempt-tier-N label names the next tier
# explicitly; estimator may not override).
# - start_tier == 0 AND estimator OFF → tier 0.
# - start_tier == 0 AND estimator ON → run estimator; on no-
# confidence / failure default to tier 0.
if start_tier > 0:
tier = start_tier
elif not estimator_enabled or getattr(cfg, "dry_run", False):
tier = _DEFAULT_START_TIER
else:
tier = _call_python_estimator(
cfg,
base_prompt,
tag=_estimator_tag_for(group, item),
on_poll=None,
redact_values=_redact_values_for(cfg),
pr_number=_estimator_cache_pr_key(item),
head_sha=_estimator_cache_head_sha(item),
)
if tier is None:
tier = _DEFAULT_START_TIER
context[_WORKER_AGENT_OVERRIDE_KEY] = _resolve_task_implementor_for_tier(tier)
item[_WORKER_AGENT_OVERRIDE_KEY] = context[_WORKER_AGENT_OVERRIDE_KEY]
# P0 / A: deterministic short-circuit.
# If the prefetch + deterministic sections + remote CI state all
# agree that no LLM work is needed (or that the only needed work
# is mechanical compliance fixes the dispatcher can apply itself),
# stash a synthetic SessionResult on the context. The dispatch
# runtime's short-circuit hook consumes it and skips the worker.
# Off by default (gated on IMPLEMENTER_DISPATCHER_AUTO_FIX_COMPLIANCE
# for the auto-fix path; the no-op skip case fires whenever
# escalation is on and conditions are met).
_maybe_short_circuit(cfg, item, context)
return _emit_task_implementor_body(base_prompt, escalation_tier=tier)
def _estimator_tag_for(group: Any, item: dict[str, Any]) -> str:
"""Tag used for the Python-side estimator session archive.
Mirrors ``_dispatch_runtime._tag_for`` so the estimator's
on-disk session lands under the same operator-recognisable
prefix as the main worker session (with ``-estimator`` appended
inside :func:`_call_python_estimator`). Named distinctly from
the existing :func:`_tag_for_item` helper (further down the
module) which takes a different signature."""
number = int(item.get("number") or 0)
return f"{group.tag_prefix}-{group.item_kind.upper()}-{number}"
def _estimator_cache_pr_key(item: dict[str, Any]) -> int | None:
"""Return the ``pr_number`` used as the estimator cache key.
PRs have a ``number`` field and a ``head`` dict; issues (no
``head``) get ``None`` returned so the caller bypasses the
cache. The cache only makes sense when ``head_sha`` can also
invalidate it; issues have no head SHA, so they're not cache-
eligible by construction.
"""
if not isinstance(item.get("head"), dict):
return None
n = item.get("number")
return int(n) if isinstance(n, int) else None
def _estimator_cache_head_sha(item: dict[str, Any]) -> str | None:
"""Return ``item["head"]["sha"]`` if present and non-empty.
Used together with :func:`_estimator_cache_pr_key` to key the
estimator cache. A new commit on the PR (different head_sha)
invalidates the cache entry implicitly.
"""
head = item.get("head")
if not isinstance(head, dict):
return None
sha = head.get("sha")
if isinstance(sha, str) and sha.strip():
return sha.strip()
return None
def _redact_values_for(cfg: Any) -> list[str]:
"""Mirror the redact-list construction
``_dispatch_runtime.dispatch_one`` does inline so the Python
estimator call redacts the Forgejo PAT in its session archive
identically to the main worker session."""
return [cfg.token] if getattr(cfg, "token", None) else []
def _estimator_heartbeat_callback(cfg: Any) -> Any:
"""Build an ``on_poll`` callback that refreshes the dispatcher's
heartbeat file once per poll iteration during a long
Python-driven estimator session.
Without this, a 30-180 s estimator call inside
:func:`_implementation_prompt_dispatch` would suppress the
dispatcher's heartbeat writes (which only fire from the main
worker session's own ``_refresh_heartbeat`` closure further
down in :func:`_dispatch_runtime.dispatch_one`). The launcher /
systemd watchdog would then see a stale heartbeat and SIGTERM
the dispatcher mid-resolution, orphaning the claim. Returns a
no-op when ``cfg.heartbeat_path`` is missing (test fixtures /
one-shot CLI runs that don't run under a watchdog).
"""
heartbeat_path = getattr(cfg, "heartbeat_path", None)
if heartbeat_path is None:
return None
def _refresh() -> None:
try:
_claim_runtime.write_heartbeat(heartbeat_path)
except Exception: # noqa: BLE001 — best-effort liveness only
_logger.exception("estimator heartbeat refresh failed (continuing)")
return _refresh
# ─── Post-session action: cleanup pre-cloned worktree ───────────────────────
# Terminal states for which the dispatcher posts an operator-status
# comment on the PR timeline (Phase 5b). ``unknown`` is intentionally
# omitted: the dispatcher genuinely doesn't know what happened, and
# posting "Worker session ended without verdict" on a PR that may
# have produced a fine fix the next cycle picks up causes operator-
# noise spam (each ``unknown`` cycle's ``outcome_reason`` differs in
# wall-clock metadata, so the fingerprint dedup at
# ``post_implementer_status_comment`` does not catch the repeats).
_NON_PUSHING_TERMINAL_STATES = frozenset(
{
"timeout",
"transport-error",
}
)
def _should_post_status(
parsed_json: dict[str, Any] | None, terminal_state: str
) -> tuple[bool, str, str]:
"""Decide whether the dispatcher should post an operator-status
comment for this cycle, and what (outcome, reason) tuple to use.
Returns ``(post, outcome, reason)``:
- ``post=True`` when the cycle ended without a successful PR
push (timeout, transport error, worker JSON ``rebase-failed``,
or no parsed JSON at all). The implementer worker's success
path posts its own attempt comment, so we don't double-post
on ``outcome=resolved``.
- ``post=False`` for the success path or for already-claimed /
claim-failed cycles where there is nothing useful to surface
to the operator.
"""
if terminal_state in _NON_PUSHING_TERMINAL_STATES:
return True, terminal_state, "Worker session ended without verdict"
if isinstance(parsed_json, dict):
outcome = str(parsed_json.get("outcome") or "")
if outcome and outcome != "resolved":
# Most commonly ``rebase-failed`` per the worker's output
# contract. Surface the worker's own outcome verbatim so
# the fingerprint dedup handles repeat failures cleanly.
files_touched = parsed_json.get("files_touched")
reason = (
f"Worker reported outcome={outcome!r}; files_touched={files_touched}"
)
return True, outcome, reason
# ``completed`` + ``outcome=resolved`` (or no JSON, but a
# ``completed`` terminal state) is the worker's success path. The
# worker already posts its own attempt comment; the dispatcher
# does not need to post a status comment.
return False, "", ""
def _resolve_work_group_name(item: dict[str, Any], explicit: str | None = None) -> str:
"""Return the precise work-group name for ``item`` (e.g.
``failing_ci_pr`` / ``request_changes_pr`` / ``new_issue``).
Production path: ``_dispatch_runtime.dispatch_one`` passes the
canonical name explicitly via the ``work_group_name`` keyword
argument; ``explicit`` is non-None and we return it verbatim.
Test / direct-invocation fallback: when the helper is called
without an explicit name (older tests, direct unit-test
invocation), fall back to the item-shape heuristic. The
heuristic returns *canonical* group names — ``failing_ci_pr``
for PR-shaped items (the priority-zero bucket; the conservative
default since fixing failing CI is the dispatcher's first
responsibility) and ``new_issue`` for issue-shaped items. We
deliberately avoid invented names like ``pr_work`` so the
Phase 4 fixture and decision-gate thresholds (which key off the
canonical names) cannot end up with a fallback row that does
not match any threshold.
"""
if isinstance(explicit, str) and explicit:
return explicit
head = item.get("head")
return "failing_ci_pr" if isinstance(head, dict) else "new_issue"
def _resolve_pre_session_head_sha(
item: dict[str, Any], context: dict[str, Any] | None
) -> str:
"""Return the freshly-fetched head_sha from the prefetch result,
falling back to ``item.head.sha`` for the legacy path. Empty
string when nothing is available — the telemetry row carries
that as ``head_sha_advanced=None``.
"""
if isinstance(context, dict):
result_obj = context.get("result")
if hasattr(result_obj, "head_sha"):
sha = str(result_obj.head_sha or "")
if sha:
return sha
head = item.get("head")
if isinstance(head, dict):
return str(head.get("sha") or "")
return ""
def _fetch_post_session_head_sha(cfg: Any, pr_number: int) -> str:
"""Best-effort GET ``/repos/.../pulls/{n}`` after the worker
session to detect a head_sha advance (i.e. the worker pushed at
least one commit). Returns empty string on failure / dry-run /
issue items.
Phase 4 telemetry's ``head_sha_advanced`` keys off this. We
deliberately do NOT raise — a transient 5xx during shutdown
must not orphan the claim release.
"""
if cfg.dry_run or pr_number <= 0:
return ""
try:
response = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr_number)}", cfg
)
except Exception as exc:
_logger.warning(
"post-session head_sha fetch failed for #%s: %s",
pr_number,
exc,
)
return ""
if int(response.get("status") or 0) != 200:
return ""
body = response.get("body") or {}
head = body.get("head") if isinstance(body, dict) else None
if isinstance(head, dict):
return str(head.get("sha") or "")
return ""
def _record_phase4_telemetry(
cfg: Any,
item: dict[str, Any],
*,
parsed_json: dict[str, Any] | None,
raw_response: str,
terminal_state: str,
context: dict[str, Any] | None,
work_group_name: str,
session_started_at: str | None,
session_completed_at: str | None,
session_wallclock_seconds: float | None,
subagent_max_depth: int | None = None,
tier_attempt_index: int | None = None,
escalation_action: str | None = None,
escalation_tier_hint: int | None = None,
outcome_synthesised: bool | None = None,
) -> dict[str, Any] | None:
"""Extract a Phase 4 telemetry row + write it to the JSONL sink.
The timing fields (``session_started_at``,
``session_completed_at``, ``session_wallclock_seconds``) come
from :func:`_dispatch_runtime.dispatch_one` which brackets
``run_session_blocking`` with two ``_now()`` calls. When the
helper is invoked outside the runtime (direct unit tests), the
callers pass ``None`` for the three fields and the row is
populated with a single ``now_iso()`` snapshot — analysts who
run aggregation against the JSONL sink will see ``None`` /
``0``-second rows for those direct invocations and can filter
them out by absence of timing data.
Returns ``{"row": <row>, "sink": <path or None>}`` on success or
``None`` if extraction itself raised (kept best-effort so a bug
in the regex extractor cannot orphan a claim release).
"""
pr_number = int(item.get("number") or 0)
pre_sha = _resolve_pre_session_head_sha(item, context)
post_sha = _fetch_post_session_head_sha(cfg, pr_number) if pre_sha else ""
fallback_now = _phase4_telemetry.now_iso()
start_ts = session_started_at or fallback_now
end_ts = session_completed_at or fallback_now
try:
row = _phase4_telemetry.extract_phase4_telemetry(
cycle_id=None,
pr_number=pr_number,
work_group=work_group_name,
start_ts=start_ts,
end_ts=end_ts,
wall_clock_seconds=session_wallclock_seconds,
parsed_json=parsed_json,
raw_response=raw_response,
terminal_state=terminal_state,
pre_session_head_sha=pre_sha,
post_session_head_sha=post_sha or None,
subagent_max_depth=subagent_max_depth,
tier_attempt_index=tier_attempt_index,
escalation_action=escalation_action,
escalation_tier_hint=escalation_tier_hint,
outcome_synthesised=outcome_synthesised,
)
except Exception as exc:
_logger.warning(
"phase4 telemetry extraction raised for #%s: %s",
pr_number,
exc,
)
return None
sink = _phase4_telemetry.write_telemetry_jsonl(row)
# C4 (2026-05-13): if this attempt produced a push, record it
# so the NEXT cycle for the same PR knows the dispatcher
# itself just contributed the current head_sha. The next
# cycle's prefetch surfaces this to the worker so it doesn't
# re-do the same fix when CI is still failing for a different
# reason (PR #28 cycle 2 failure mode from the live test).
if post_sha and pre_sha and post_sha != pre_sha:
try:
outcome_str = ""
if isinstance(parsed_json, dict):
outcome_str = str(parsed_json.get("outcome") or "")
_recent_push_cache.record_push(
pr_number,
post_sha,
terminal_state=terminal_state,
outcome=outcome_str,
)
except Exception as exc:
_logger.warning(
"recent-push cache record failed for #%s: %s",
pr_number,
exc,
)
return {"row": row, "sink": str(sink) if sink else None}
def _cleanup_clone_handle(
cfg: Any, item: dict[str, Any], context: dict[str, Any] | None
) -> tuple[bool, str | None]:
"""Cleanup the pre-cloned worktree (when one was created in
Phase 3) and return ``(attempted, error_string)``. Best-effort
— failures (the worker ``rm -rf``'d the dir, disk full, etc.)
are logged + reported but do NOT raise.
Also removes the PR-context sentinel — even when no clone was
materialised the dispatcher may have written one (the two
sentinels are independent), so this runs unconditionally for
any item with a PR number. ``handle.cleanup`` already removes
the workspace sentinel; the PR-context sentinel lives in a
different directory and needs its own cleanup call.
"""
pr_number = int(item.get("number") or 0)
if pr_number > 0:
try:
_pr_context_sentinel.delete(pr_number)
except Exception as e: # noqa: BLE001 — best-effort cleanup
_logger.warning(
"PR context sentinel cleanup raised for #%s: %s",
pr_number,
e,
)
if not isinstance(context, dict):
return False, None
handle = context.get("clone_handle")
if handle is None:
return False, None
try:
handle.cleanup(cfg)
except Exception as exc:
error_repr = f"{type(exc).__name__}: {exc}"
_logger.warning(
"implementer pre-clone cleanup raised for #%s: %s",
item.get("number"),
exc,
)
return True, error_repr
return True, None
def _maybe_post_status_comment(
cfg: Any,
item: dict[str, Any],
*,
parsed_json: dict[str, Any] | None,
terminal_state: str,
context: dict[str, Any] | None,
tier: int | None = None,
) -> dict[str, Any] | None:
"""Post a per-fingerprint operator-status comment when the cycle
ended without a successful PR push.
Tri-state return value (chosen so cycle-archive consumers can
distinguish "not applicable" from "ran and skipped" without
inspecting the Forgejo POST log):
- ``None`` — gate did not fire (dry-run, issue work item, or
worker reported a successful resolved push). The cycle archive
reads ``post_session_result.status_comment is None`` as
"no status comment expected for this cycle."
- ``{..., "skipped_duplicate": True}`` — gate fired but a
matching fingerprint already existed on the timeline; no POST
was issued.
- ``{..., "skipped_duplicate": False, "status": 2xx, ...}`` —
gate fired AND posted; ``status`` is the Forgejo HTTP code.
- ``{..., "status": 0, "error": "..."}`` — POST raised; we
swallow + log so claim release isn't orphaned.
"""
pr_number = int(item.get("number") or 0)
item_kind_is_pr = bool(item.get("head"))
if cfg.dry_run or pr_number <= 0 or not item_kind_is_pr:
return None
should_post, outcome, reason = _should_post_status(parsed_json, terminal_state)
if not should_post:
return None
pr_comments: list[dict[str, Any]] | None = None
if isinstance(context, dict):
result = context.get("result")
if hasattr(result, "pr_comments"):
pr_comments = list(result.pr_comments) if result.pr_comments else []
try:
post_result = _review_post.post_implementer_status_comment(
cfg,
pr_number,
outcome=outcome,
outcome_reason=reason,
terminal_state=terminal_state,
pr_comments=pr_comments,
tier=tier,
)
return {
"fingerprint": post_result.get("fingerprint"),
"skipped_duplicate": post_result.get("skipped_duplicate"),
"status": post_result.get("status"),
}
except Exception as exc:
_logger.warning(
"post_implementer_status_comment failed for #%s: %s",
pr_number,
exc,
)
return {
"fingerprint": None,
"skipped_duplicate": False,
"status": 0,
"error": f"{type(exc).__name__}: {exc}",
}
def _post_session_action(
cfg: Any,
item: dict[str, Any],
parsed_json: dict[str, Any] | None,
raw_response: str,
terminal_state: str,
*,
session_context: Any | None = None,
work_group_name: str | None = None,
session_started_at: str | None = None,
session_completed_at: str | None = None,
session_wallclock_seconds: float | None = None,
subagent_max_depth: int | None = None,
) -> dict[str, Any]:
"""Cleanup + status-comment hook the dispatcher runs after the
worker session.
Three responsibilities, each in its own helper:
1. :func:`_record_phase4_telemetry` — extract a Phase 4 row from
the session output and write it to the JSONL sink (when the
:data:`_phase4_telemetry.PHASE4_TELEMETRY_ENV_VAR` env var
points at a directory).
2. :func:`_cleanup_clone_handle` — remove the pre-cloned
worktree (when one was created — Phase 3 gating).
3. :func:`_maybe_post_status_comment` — post a per-fingerprint
operator-status comment when the cycle ended without a
successful PR push (Phase 5b).
Inputs come from :func:`_dispatch_runtime.dispatch_one`:
- ``session_context`` (preferred) — a
:class:`_dispatch_runtime.SessionContext` dataclass packing
``work_group_name`` plus session-timing fields. The dispatcher
always passes this kwarg; direct test callers may pass the
legacy flat kwargs instead (see below).
- ``work_group_name`` / ``session_started_at`` /
``session_completed_at`` / ``session_wallclock_seconds`` —
legacy flat kwargs. Used only when ``session_context`` is
absent (back-compat for tests written against the previous
API). New code should always pass ``session_context``.
The status comment is only posted for items that had a real PR
number — ``new_issue`` cycles that never opened a PR have no
timeline to post on. Skipped on dry-run.
Returns a dict that gets merged into the cycle archive under
``post_session_result`` so an operator can see whether cleanup +
status fired. Errors propagate as logged warnings; raising would
orphan the claim release.
"""
if session_context is not None:
# Unpack the dataclass; the flat-kwargs path is only the
# back-compat tail for direct test callers.
work_group_name = session_context.work_group_name
session_started_at = session_context.session_started_at
session_completed_at = session_context.session_completed_at
session_wallclock_seconds = session_context.session_wallclock_seconds
# ``subagent_max_depth`` lives on SessionContext but is new
# (Tier-1 R2): older tests may construct a SessionContext
# without it. ``getattr`` with the default preserves them.
subagent_max_depth = getattr(session_context, "subagent_max_depth", None)
context = item.get("_dispatcher_implementer_context")
context_dict = context if isinstance(context, dict) else None
resolved_group_name = _resolve_work_group_name(item, work_group_name)
out: dict[str, Any] = {
"cleanup_attempted": False,
"cleanup_error": None,
"status_comment": None,
"phase4_telemetry": None,
"work_group_name": resolved_group_name,
}
# Surface the dispatcher's deterministic-fix report (P0 / A) when
# the cycle was short-circuited by ``_maybe_short_circuit``. An
# operator inspecting cycle archives sees ``auto_fix_report`` ==
# None on a normal worker cycle and a populated dict when the
# dispatcher applied compliance fixes itself.
out["auto_fix_report"] = (
context_dict.get("_auto_fix_report") if context_dict else None
)
out["short_circuit"] = bool(
context_dict and context_dict.get("_short_circuit_consumed")
)
out["phase4_telemetry"] = _record_phase4_telemetry(
cfg,
item,
parsed_json=parsed_json,
raw_response=raw_response,
terminal_state=terminal_state,
context=context_dict,
work_group_name=resolved_group_name,
session_started_at=session_started_at,
session_completed_at=session_completed_at,
session_wallclock_seconds=session_wallclock_seconds,
subagent_max_depth=subagent_max_depth,
)
out["cleanup_attempted"], out["cleanup_error"] = _cleanup_clone_handle(
cfg, item, context_dict
)
out["status_comment"] = _maybe_post_status_comment(
cfg,
item,
parsed_json=parsed_json,
terminal_state=terminal_state,
context=context_dict,
)
return out
# ─── In-cycle tier escalation (flag-gated) ──────────────────────────────────
# Extracted to :mod:`_implementer_escalation_helpers`; this thin
# closure forwards the dispatcher's ``_claim_runtime`` reference so
# test monkeypatches on ``driver._claim_runtime.get`` propagate
# correctly into the helper.
def _fetch_pr_state(cfg: Any, pr_number: int) -> str:
return _escalation_helpers.fetch_pr_state(
cfg,
pr_number,
claim_runtime=_claim_runtime,
)
# R3.7 post-push CI verification (2026-05-17). When the worker
# claims ``outcome=resolved`` AND head_sha advanced (a real push), the
# dispatcher polls Forgejo CI on the new head_sha for up to this many
# seconds. If CI lands in a terminal failure state within the budget,
# the dispatcher REWRITES the worker's outcome from ``resolved`` to
# ``post-push-ci-failed`` so the downstream escalation predicate
# treats the cycle as a failure (not a success). This closes the
# local-vs-remote-CI divergence the 2026-05-17 run-7 observation
# exposed — the worker's ``ci_run_local_gate --fast`` doesn't catch
# what remote CI does, so the worker can push a commit + claim
# ``resolved`` even when remote CI will reject it. With this gate
# in place, the dispatcher is the source of truth for "did this PR
# actually pass CI."
#
# Tuning:
# - Default 180 s: catches the fast-failing checks (lint, format,
# push-validation typically fail in 30-60 s) AND gives headroom
# for a backlogged runner where those same checks may queue for
# a minute or two before they actually run. 90 s was the earlier
# default; bumped after observing healthy slow-runner days where
# the verifier returned ``pending`` and let bad pushes through to
# the next cycle.
# - 10 s poll interval: 18 polls per budget window. Forgejo's CI
# status endpoint is fast (< 1 s typical).
# - On budget exhaustion the verifier RETURNS the outcome unchanged
# (does NOT rewrite to a failure) — we don't penalise the worker
# for slow CI. The next cycle will re-classify if CI eventually
# fails.
_POST_PUSH_CI_VERIFY_BUDGET_S = int(
os.environ.get("IMPLEMENTER_POST_PUSH_CI_VERIFY_S", "180")
)
_POST_PUSH_CI_VERIFY_POLL_INTERVAL_S = int(
os.environ.get("IMPLEMENTER_POST_PUSH_CI_VERIFY_POLL_S", "10")
)
_POST_PUSH_CI_TERMINAL_FAIL_STATES = frozenset({"failure", "error"})
_POST_PUSH_CI_TERMINAL_PASS_STATES = frozenset({"success"})
# Per-cycle polling-time budget (sliding window). Without this, a
# dispatcher cycle that processes N PRs each landing in
# ``outcome=resolved`` after a push could spend
# ``N × _POST_PUSH_CI_VERIFY_BUDGET_S`` seconds polling — at the
# default 180 s per call and 5 PRs/cycle, that's 15 min of polling
# eating the cycle budget. The cycle budget caps the cumulative
# polling time across all ``_verify_post_push_ci`` calls in a single
# dispatcher cycle.
#
# Window resets automatically when more than
# ``_POST_PUSH_CI_CYCLE_RESET_AFTER_S`` seconds pass between calls —
# that's the dispatcher's cycle boundary in practice, since cycles
# are typically several seconds apart. Avoids requiring the
# dispatcher's outer loop to remember to call a reset hook.
_POST_PUSH_CI_CYCLE_BUDGET_S = float(
os.environ.get("IMPLEMENTER_POST_PUSH_CI_CYCLE_BUDGET_S", "300")
)
_POST_PUSH_CI_CYCLE_RESET_AFTER_S = float(
os.environ.get("IMPLEMENTER_POST_PUSH_CI_CYCLE_RESET_AFTER_S", "120")
)
_post_push_cycle_seconds_spent: float = 0.0
_post_push_cycle_last_call_at: float = 0.0
def reset_post_push_ci_cycle_budget() -> None:
"""Reset the per-cycle polling-time budget. Called automatically
when more than ``_POST_PUSH_CI_CYCLE_RESET_AFTER_S`` seconds
have passed since the last call; exposed for tests that want
to reset between assertions and for any future dispatcher hook
that wants to reset explicitly at cycle boundaries."""
global _post_push_cycle_seconds_spent, _post_push_cycle_last_call_at
_post_push_cycle_seconds_spent = 0.0
_post_push_cycle_last_call_at = 0.0
def _maybe_reset_cycle_budget() -> None:
"""Auto-reset the budget if the dispatcher has been idle long
enough that we're clearly in a new cycle."""
global _post_push_cycle_seconds_spent, _post_push_cycle_last_call_at
now = time.monotonic()
if _post_push_cycle_last_call_at == 0.0:
_post_push_cycle_last_call_at = now
return
if (now - _post_push_cycle_last_call_at) > _POST_PUSH_CI_CYCLE_RESET_AFTER_S:
_post_push_cycle_seconds_spent = 0.0
_post_push_cycle_last_call_at = now
def _post_push_ci_verify_enabled() -> bool:
"""Feature flag for the R3.7 post-push CI verifier. Default ``"1"``
(enabled in production). Tests that exercise the escalation path
without stubbing ``fetch_ci_status`` set ``"0"`` via an autouse
fixture so the verifier short-circuits instead of polling the
FakeReviewAPI default (which returns ``[]`` and would burn the
full 180 s budget on a real sleep)."""
return str(
os.environ.get("IMPLEMENTER_POST_PUSH_CI_VERIFY", "1")
).strip().lower() in {"1", "true", "yes", "on"}
def _verify_post_push_ci(
cfg: Any,
pr_number: int,
parsed_json: dict[str, Any] | None,
head_sha_advanced: bool | None,
) -> dict[str, Any] | None:
"""Verify the worker's ``outcome=resolved`` claim against remote
CI. Returns the (possibly-rewritten) ``parsed_json``.
The verifier ONLY runs when:
- ``parsed_json`` is a dict with ``outcome=resolved``, AND
- ``head_sha_advanced is True`` (a real push happened — no
verification needed if the worker didn't push).
Polls ``fetch_ci_status(cfg, new_head_sha)`` every
:data:`_POST_PUSH_CI_VERIFY_POLL_INTERVAL_S` seconds for up to
:data:`_POST_PUSH_CI_VERIFY_BUDGET_S` seconds total. Verdict:
- CI state ∈ ``{success}``: outcome unchanged (worker's
``resolved`` claim verified).
- CI state ∈ ``{failure, error}``: outcome REWRITTEN to
``post-push-ci-failed``; the original outcome + the list of
failing contexts are stashed under ``_post_push_ci_verification``
for telemetry / debugging. The downstream
``_implementer_escalation.decide`` will see a non-success
outcome and route to ESCALATE / EXHAUSTED as appropriate.
- CI still in ``{pending}`` after the budget expires: outcome
unchanged. We don't penalise the worker for slow CI — the
next dispatcher cycle will re-classify the PR when CI lands.
- Fetch fails (transport error, no status returned): outcome
unchanged. Treating "I couldn't check" as "the worker lied"
would create false positives on Forgejo flakes.
Dry-run short-circuits to no-op so ``--dry-run`` cycles don't
burn 180 s polling a real Forgejo endpoint.
"""
if not _post_push_ci_verify_enabled():
return parsed_json
if getattr(cfg, "dry_run", False):
return parsed_json
if not isinstance(parsed_json, dict):
return parsed_json
if parsed_json.get("outcome") != "resolved":
return parsed_json
if head_sha_advanced is not True:
# head_sha_advanced is False → worker claims resolved but
# didn't push → escalation handler already routes this case
# (head_sha_advanced=False + outcome=resolved → ESCALATE per
# _implementer_escalation.decide).
# head_sha_advanced is None → fetch failed → already routes
# to RETRY_POST_FETCH.
# Both are handled downstream; nothing to verify here.
return parsed_json
# Per-cycle budget gate. Auto-resets after
# ``_POST_PUSH_CI_CYCLE_RESET_AFTER_S`` seconds of idle (which
# is well under any dispatcher cycle interval), so a true new
# cycle gets a fresh budget without the dispatcher's outer loop
# having to call a reset hook.
global _post_push_cycle_seconds_spent
_maybe_reset_cycle_budget()
if _post_push_cycle_seconds_spent >= _POST_PUSH_CI_CYCLE_BUDGET_S:
_logger.warning(
"post-push CI verify: PR #%s skipped — per-cycle polling "
"budget exhausted (%.1fs/%.1fs spent across earlier calls). "
"Leaving outcome unchanged; next cycle will re-verify.",
pr_number,
_post_push_cycle_seconds_spent,
_POST_PUSH_CI_CYCLE_BUDGET_S,
)
return parsed_json
# Re-fetch the post-push head_sha (we know it advanced; fetch
# fresh to poll against the right SHA).
new_head_sha = _fetch_post_session_head_sha(cfg, pr_number)
if not new_head_sha:
_logger.info(
"post-push CI verify: PR #%s head_sha_advanced=True but "
"post-session head_sha fetch failed; skipping verification "
"(outcome unchanged)",
pr_number,
)
return parsed_json
# Per-call budget is the min of the configured per-call budget
# and what's left in the cycle budget — so a near-exhausted
# cycle budget doesn't get blown through by one big call.
per_call_budget_s = min(
_POST_PUSH_CI_VERIFY_BUDGET_S,
max(0, int(_POST_PUSH_CI_CYCLE_BUDGET_S - _post_push_cycle_seconds_spent)),
)
elapsed = 0
final_state: str | None = None
failing_contexts: list[str] = []
while elapsed <= per_call_budget_s:
try:
ci = _review_fetch.fetch_ci_status(cfg, new_head_sha)
except Exception as exc: # noqa: BLE001 — best-effort verify
_logger.warning(
"post-push CI verify: PR #%s fetch_ci_status raised %s; "
"leaving outcome unchanged (won't penalise worker for "
"Forgejo flake)",
pr_number,
exc,
)
return parsed_json
if isinstance(ci, dict):
state = str(ci.get("state") or "").lower()
if state in _POST_PUSH_CI_TERMINAL_PASS_STATES:
_logger.info(
"post-push CI verify: PR #%s @ %s passed after %ds — "
"worker's resolved claim verified",
pr_number,
new_head_sha[:12],
elapsed,
)
return parsed_json
if state in _POST_PUSH_CI_TERMINAL_FAIL_STATES:
final_state = state
failing_contexts = sorted(
{
str(s.get("context") or "")
for s in (ci.get("statuses") or [])
if isinstance(s, dict)
and str(s.get("state") or "").lower()
in _POST_PUSH_CI_TERMINAL_FAIL_STATES
}
)
break
if elapsed + _POST_PUSH_CI_VERIFY_POLL_INTERVAL_S > per_call_budget_s:
break
time.sleep(_POST_PUSH_CI_VERIFY_POLL_INTERVAL_S)
elapsed += _POST_PUSH_CI_VERIFY_POLL_INTERVAL_S
# Charge actual polling time to the per-cycle budget so the
# next call in this dispatcher cycle sees the cumulative cost.
_post_push_cycle_seconds_spent += elapsed
if final_state in _POST_PUSH_CI_TERMINAL_FAIL_STATES:
_logger.warning(
"post-push CI verify: PR #%s @ %s — worker claimed "
"outcome=resolved but remote CI is %s (failing: %s). "
"Rewriting outcome to 'post-push-ci-failed' so escalation "
"treats this as a failure (closes the local-vs-remote-CI "
"divergence gap).",
pr_number,
new_head_sha[:12],
final_state,
failing_contexts,
)
return {
**parsed_json,
"outcome": "post-push-ci-failed",
"_post_push_ci_verification": {
"head_sha": new_head_sha,
"ci_state": final_state,
"failing_contexts": failing_contexts,
"elapsed_seconds": elapsed,
"original_outcome": parsed_json.get("outcome"),
},
}
# Budget exhausted with CI still pending → outcome unchanged.
_logger.info(
"post-push CI verify: PR #%s @ %s CI still pending after %ds; "
"leaving worker's resolved claim intact (next cycle will "
"re-classify when CI lands)",
pr_number,
new_head_sha[:12],
elapsed,
)
return parsed_json
def _compute_head_sha_tristate(cfg: Any, pr_number: int, pre_sha: str) -> bool | None:
"""Tri-state computation of ``head_sha_advanced``.
- ``True`` — verified the worker pushed (pre != post, both set).
- ``False`` — verified the worker did NOT push (pre == post).
- ``None`` — fetch failed or pre_sha unknown; the predicate
treats this distinctly (e.g. ``outcome=resolved`` + ``None`` →
:data:`_implementer_escalation.EscalationAction.RETRY_POST_FETCH`).
Mirrors the dispatcher's existing head_sha_advanced derivation
(in :mod:`_phase4_telemetry`) but exposes the tri-state for the
escalation predicate rather than collapsing ``None`` to ``False``.
"""
if not pre_sha:
return None
post_sha = _fetch_post_session_head_sha(cfg, pr_number)
if not post_sha:
return None
return pre_sha != post_sha
_reset_worktree_to_pinned_sha = _escalation_helpers.reset_worktree_to_pinned_sha
def _read_start_tier_from_labels(cfg: Any, pr_number: int) -> int:
"""Read ``auto/last-attempt-tier-N`` from the PR's labels and
return the tier the dispatcher should START this cycle at.
Policy: ``start_tier = min(labeled_tier + 1, max_tier)``. The
label captures "we already tried this tier and it failed (or
crashed mid-run)"; the next cycle skips ahead one tier so the
same model isn't asked to redo the same work. When the labeled
tier equals ``max_tier``, the cap stops further escalation —
the cycle just retries the top tier (effectively a no-op
promotion).
Returns ``0`` when the PR has no attempt label, when the fetch
fails, or in dry-run mode. The label is observability AND
seed-state (per the 2026-05-12 decision: we wrote it for a
reason; use it).
Conservative side: a dispatcher that crashes between LAUNCH and
a successful end-of-cycle could leave a label that overshoots
the actual work done. Worst case: the next cycle skips ahead
and tries a higher tier than strictly needed. The cost is one
higher-tier worker call vs. the savings of skipping a known-
failed tier — favourable on average.
"""
if cfg.dry_run or pr_number <= 0:
return 0
try:
response = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/issues/{int(pr_number)}/labels",
cfg,
)
except Exception as exc:
_logger.warning(
"label fetch for start-tier failed for #%s (starting at Tier 0): %s",
pr_number,
exc,
)
return 0
if int(response.get("status") or 0) != 200:
return 0
body = response.get("body")
if not isinstance(body, list):
return 0
# Find the highest labelled tier (defensive — a PR shouldn't
# carry multiple last-attempt-tier-* labels because
# ``apply_attempt_label`` clears siblings, but the same PR
# processed by two dispatcher versions could have stale ones).
# Bound-check the parsed integer against the known label set
# so an operator-introduced ``auto/last-attempt-tier-9`` typo
# doesn't silently get capped — explicitly skip out-of-range
# values and log so the stale-label state is operator-visible.
#
# 2026-05-16: the label set was expanded to include
# ``auto/last-attempt-tier-min`` (mapped to tier -1). The literal
# ``-min`` suffix doesn't parse as an integer, so we branch
# on the trailing token before the int() conversion. A labelled
# tier of -1 yields start_tier = max(min(-1+1, max_tier), 0) = 0
# — the first non-min tier, which is the correct deterministic
# escalation from tier-min.
valid_tiers = set(_implementer_label_state.ATTEMPT_TIER_LABELS_BY_TIER)
# Sentinel ``None`` distinguishes "no label found" from a real
# tier of -1 (tier-min), which IS the lowest tier in the new
# ladder. The earlier code reused ``-1`` for both, which the
# tier-min addition would collide with.
highest_labelled: int | None = None
for label in body:
if not isinstance(label, dict):
continue
name = label.get("name")
if not isinstance(name, str):
continue
if not name.startswith("auto/last-attempt-tier-"):
continue
suffix = name.rsplit("-", 1)[-1]
if suffix == "min":
n = -1
else:
try:
n = int(suffix)
except ValueError:
continue
if n not in valid_tiers:
_logger.warning(
"PR #%s carries out-of-range attempt label %r "
"(valid tiers: %s); skipping for start-tier seed",
pr_number,
name,
sorted(valid_tiers),
)
continue
if highest_labelled is None or n > highest_labelled:
highest_labelled = n
if highest_labelled is None:
return 0
return min(highest_labelled + 1, _max_tier_for_cycle())
def _build_prompt_for_tier(
cfg: Any, item: dict[str, Any], group: Any, tier: int
) -> str:
"""Build the worker prompt for an explicit tier attempt.
Always rebuilds the base prompt fresh (no recursion through
:func:`_implementation_prompt_dispatch`) so the
``escalation_tier`` line is appended cleanly. After R3 cutover
the body is fed directly to the matching
``task-implementor-tier-<slot>`` variant — no wrapper agent
reads it on the way down, so the prompt format mirrors what
``task-implementor`` saw at depth-3 under the old chain.
Used by:
- The escalation loop in :func:`_post_session_action_with_escalation`
for tier > 0 attempts.
- :func:`_implementation_prompt_dispatch` for the initial cycle
prompt when cross-cycle resumption seeds ``start_tier > 0``.
"""
if _is_prefetch_enabled():
base_prompt = _prefetch_prompt(cfg, item, group)
else:
base_prompt = _legacy_implementation_prompt(cfg, item, group)
return _emit_task_implementor_body(base_prompt, escalation_tier=tier)
_per_tier_worker_timeout = _escalation_helpers.per_tier_worker_timeout
def _run_worker_at_tier(
cfg: Any,
group: Any,
item: dict[str, Any],
tier: int,
*,
tag: str,
on_poll: Any,
redact_values: list[str],
) -> Any:
"""Spawn a worker session for ``tier`` and return its
:class:`_opencode_worker.SessionResult`.
Wraps :func:`_opencode_worker.run_session_blocking` so the
escalation loop can call it for each subsequent tier without
duplicating the redact / heartbeat plumbing. ``tag`` /
``on_poll`` / ``redact_values`` are reused from the original
``dispatch_one`` invocation that spawned Tier 0 — the worker
session for Tier N inherits the same tag so an operator
grepping the cycle archive sees all attempts under one PR.
Timeout is resolved per-tier (B2 fix) — Tier 1+ models legitimately
need more wallclock than Tier 0. See
:func:`_per_tier_worker_timeout`.
After R3 cutover the agent name is resolved per-tier to the
matching ``task-implementor-tier-<slot>`` variant — each
variant carries the tier's model via ``opencode.json``'s
``agent.<name>.model`` block, so the model is enforced at
session-create time rather than inherited through a wrapper
agent. ``group.worker_agent`` is no longer consulted.
"""
prompt = _build_prompt_for_tier(cfg, item, group, tier)
return _opencode_worker.run_session_blocking(
server_url=cfg.server_url,
agent=_resolve_task_implementor_for_tier(tier),
tag=tag,
prompt=prompt,
timeout_seconds=_per_tier_worker_timeout(cfg, tier),
on_poll=on_poll,
redact_values=redact_values,
)
_terminal_state_from_session = _escalation_helpers.terminal_state_from_session
# Map worker terminal_state → synthesised outcome string when the
# worker emitted no parseable JSON. Routes the dispatcher's UNKNOWN
# bucket waste into a clean competence-class signal.
_SYNTHESIZED_OUTCOME_FROM_TERMINAL_STATE: dict[str, str] = {
"completed": "rebase-failed",
"timeout": "timeout",
"transport-error": "transport-error",
}
def _synthesize_outcome_if_missing(
parsed_json: dict[str, Any] | None,
terminal_state: str,
) -> tuple[dict[str, Any] | None, bool]:
"""Return ``(possibly-synthesized parsed_json, was_synthesized)``.
When the worker emitted a parseable JSON with a string
``outcome``, pass through unchanged. Otherwise synthesize a
``{"outcome": "<mapped>", "files_touched": [], "_synthesized":
True}`` dict so the escalation predicate sees a competence-class
signal instead of falling into the UNKNOWN bucket (which costs
one wasted same-tier retry per cycle).
The ``_synthesized`` flag is captured in the Phase 4 telemetry
row (as ``outcome_synthesised``) so an operator analysing the
JSONL sink can distinguish cycles where the worker explicitly
reported failure from cycles where the dispatcher synthesised a
verdict.
Gated by :func:`_is_outcome_synthesis_enabled`. When that returns
False, the function passes through whatever ``parsed_json`` was
(including ``None``) with ``was_synthesized=False``; the
downstream escalation predicate will then see the legacy
UNKNOWN bucket and behave byte-equivalent to the pre-feature
build.
"""
if isinstance(parsed_json, dict):
outcome = parsed_json.get("outcome")
if isinstance(outcome, str) and outcome:
return parsed_json, False
if not _is_outcome_synthesis_enabled():
return parsed_json, False
synthesized_outcome = _SYNTHESIZED_OUTCOME_FROM_TERMINAL_STATE.get(
terminal_state, "unknown"
)
return (
{
"outcome": synthesized_outcome,
"files_touched": [],
"_synthesized": True,
},
True,
)
def _post_session_action_with_escalation(
cfg: Any,
item: dict[str, Any],
parsed_json: dict[str, Any] | None,
raw_response: str,
terminal_state: str,
*,
session_context: Any | None = None,
work_group_name: str | None = None,
session_started_at: str | None = None,
session_completed_at: str | None = None,
session_wallclock_seconds: float | None = None,
subagent_max_depth: int | None = None,
) -> dict[str, Any]:
"""Flag=1 post-session action: in-cycle tier escalation.
Runs immediately after the dispatcher's Tier 0 worker session.
Performs the same per-attempt work as
:func:`_post_session_action` (Phase 4 telemetry, status comment),
then evaluates :func:`_implementer_escalation.decide`. If
escalation is warranted, runs subsequent tiers in the same
process — refreshing the claim TTL via
:func:`_claim_runtime.claim_pr` (the agent-side
``claim_pr.ts --action claim`` does NOT bump TTL on re-claim, see
plan §"Audit finding"), resetting the pre-cloned worktree to the
prefetched head_sha, and mutating the
``auto/last-attempt-tier-N`` labels for operator visibility.
Falls back to the legacy single-shot path
(:func:`_post_session_action`) when the item is not PR-shaped
(``new_issue`` work group) — the plan explicitly scopes
escalation to PR work for v1.
The escalation loop is bounded by the failure-class budgets
encoded in :mod:`_implementer_escalation`: at most
``max_tier + 1`` worker sessions per cycle, with 2 retries at
same tier for transport-class failures (default; see
:data:`_implementer_escalation.BUDGET_PER_FAILURE_CLASS`). The
worst-case cycle wallclock is ``(max_tier + 1) * worker_timeout``
which v1 caps at 2 × 7200s = 4h with Tier 0→1 only.
"""
# Issue work bypasses the escalation harness — no PR exists yet
# to mutate labels on or hold a claim across.
is_pr_shape = isinstance(item.get("head"), dict)
if not is_pr_shape:
return _post_session_action(
cfg,
item,
parsed_json,
raw_response,
terminal_state,
session_context=session_context,
work_group_name=work_group_name,
session_started_at=session_started_at,
session_completed_at=session_completed_at,
session_wallclock_seconds=session_wallclock_seconds,
subagent_max_depth=subagent_max_depth,
)
if session_context is not None:
work_group_name = session_context.work_group_name
session_started_at = session_context.session_started_at
session_completed_at = session_context.session_completed_at
session_wallclock_seconds = session_context.session_wallclock_seconds
subagent_max_depth = getattr(session_context, "subagent_max_depth", None)
# Heartbeat callback threaded from dispatch_one (P0 fix
# 2026-05-12). Without it, Tier 1+ sessions ran with a
# no-op on_poll and the heartbeat file went stale for the
# entire session duration — systemd watchdog could SIGTERM
# the dispatcher mid-escalation. ``None`` (back-compat for
# tests that constructed SessionContext without it) falls
# back to a no-op closure that still satisfies the worker's
# callable contract.
heartbeat_callback = getattr(session_context, "heartbeat_callback", None) or (
lambda: None
)
else:
heartbeat_callback = lambda: None # noqa: E731 — legacy path
pr_number = int(item.get("number") or 0)
context_dict = item.get("_dispatcher_implementer_context")
if not isinstance(context_dict, dict):
context_dict = None
resolved_group_name = _resolve_work_group_name(item, work_group_name)
clone_handle = context_dict.get("clone_handle") if context_dict else None
pre_sha = _resolve_pre_session_head_sha(item, context_dict)
# Tier 0 per-attempt work: phase4 row + per-tier status comment.
# We reuse the existing helpers; the only difference vs. the
# legacy path is that we mark the attempt index for telemetry
# (TODO #6 wires this into the phase4 schema; until then the
# extractor silently ignores the kwarg).
attempts: list[dict[str, Any]] = []
_logger.info(
"escalation: starting in-cycle loop for PR #%s (max_tier=%d)",
pr_number,
_max_tier_for_cycle(),
)
def _record_attempt(
tier_idx: int,
parsed: dict[str, Any] | None,
raw: str,
term_state: str,
wallclock: float | None,
depth: int | None,
action_str: str | None = None,
outcome_synthesised: bool | None = None,
) -> dict[str, Any]:
"""Per-attempt side effects: telemetry write + per-tier
status comment. Returns a dict for the attempts log."""
phase4 = _record_phase4_telemetry(
cfg,
item,
parsed_json=parsed,
raw_response=raw,
terminal_state=term_state,
context=context_dict,
work_group_name=resolved_group_name,
session_started_at=session_started_at,
session_completed_at=session_completed_at,
session_wallclock_seconds=wallclock,
subagent_max_depth=depth,
tier_attempt_index=tier_idx,
escalation_action=action_str,
escalation_tier_hint=tier_idx,
outcome_synthesised=outcome_synthesised,
)
status_comment = _maybe_post_status_comment(
cfg,
item,
parsed_json=parsed,
terminal_state=term_state,
context=context_dict,
tier=tier_idx,
)
return {
"tier": tier_idx,
"terminal_state": term_state,
"escalation_action": action_str,
"phase4_telemetry": phase4,
"status_comment": status_comment,
}
# Synthesise an outcome JSON when the worker produced none —
# turns the dispatcher's UNKNOWN-bucket waste into a clean
# competence-class signal. Recorded on the per-attempt
# telemetry row as ``outcome_synthesised=true``.
parsed_json, t0_outcome_synthesised = _synthesize_outcome_if_missing(
parsed_json,
terminal_state,
)
# ─── Initial attempt (cross-cycle resumption aware) ────────
# ``start_tier`` was seeded by :func:`_implementation_prompt_dispatch`
# via the ``auto/last-attempt-tier-N`` label fetch. When this
# PR has never escalated before (or in dry-run / fetch-failed
# cases), it defaults to 0 — byte-equivalent to a first-attempt
# cycle.
start_tier = 0
if isinstance(context_dict, dict):
try:
stashed = int(context_dict.get("start_tier") or 0)
except (TypeError, ValueError):
stashed = 0
start_tier = max(0, min(stashed, _max_tier_for_cycle()))
# Only record the attempt label when we got a CONCLUSIVE verdict
# from the worker — outcome JSON parsed (or synthesised from a
# ``completed`` terminal_state), not a ``timeout`` /
# ``transport-error`` that never ran to a verdict. Without this
# gate, an environmental failure (network blip, OpenCode 5xx)
# writes ``auto/last-attempt-tier-N`` despite the worker never
# really attempting tier N; the next cycle's
# ``_read_start_tier_from_labels`` then bumps to N+1, wasting a
# tier on a fix the lower one might have handled.
# ``_synthesize_outcome_if_missing`` upstream has already turned
# a JSON-less ``completed`` session into a synthetic outcome, so
# a missing parsed_json at this point means the session itself
# didn't complete — the only safe interpretation is "this tier
# was never really tried."
if (
not cfg.dry_run
and terminal_state == "completed"
and isinstance(parsed_json, dict)
and parsed_json.get("outcome")
):
# Best-effort: a label-add failure (unprovisioned label,
# transient Forgejo 5xx, ValueError on out-of-range tier)
# must not kill the cycle before the post-session telemetry
# write, the cleanup, and the claim release fire. The
# symmetric ``clear_attempt_labels`` call at end-of-cycle
# is already wrapped; mirror that here.
try:
_implementer_label_state.apply_attempt_label(
cfg,
pr_number,
start_tier,
)
except Exception as exc:
_logger.warning(
"apply_attempt_label(%d) failed for #%s "
"(continuing without per-tier label observability): %s",
start_tier,
pr_number,
exc,
)
elif not cfg.dry_run:
_logger.info(
"skipped apply_attempt_label(%d) for #%s"
"terminal_state=%s outcome=%s (no conclusive verdict; "
"leaving any prior tier label intact so next cycle "
"doesn't false-bump the start tier)",
start_tier,
pr_number,
terminal_state,
(parsed_json or {}).get("outcome")
if isinstance(parsed_json, dict)
else None,
)
head_sha_advanced = _compute_head_sha_tristate(cfg, pr_number, pre_sha)
pr_state = _fetch_pr_state(cfg, pr_number)
# R3.7 post-push CI verification — if the worker claimed
# ``resolved`` AND head_sha advanced, poll remote CI and reject
# the claim if CI failed. parsed_json may be rewritten to
# ``outcome="post-push-ci-failed"`` so the escalation decision
# below sees the truth instead of the worker's optimistic claim.
parsed_json = _verify_post_push_ci(
cfg,
pr_number,
parsed_json,
head_sha_advanced,
)
action = _implementer_escalation.decide(
parsed_json=parsed_json,
terminal_state=terminal_state,
head_sha_advanced=head_sha_advanced,
pr_state=pr_state,
transport_retries_used=0,
current_tier=start_tier,
max_tier=_max_tier_for_cycle(),
)
attempts.append(
_record_attempt(
start_tier,
parsed_json,
raw_response,
terminal_state,
session_wallclock_seconds,
subagent_max_depth,
action_str=action.value,
outcome_synthesised=t0_outcome_synthesised,
)
)
EscAction = _implementer_escalation.EscalationAction
current_tier = start_tier
transport_retries = 0
# ``last_*`` carry the most-recent worker session's outputs so
# the loop's decide() call sees fresh data each iteration.
last_parsed = parsed_json
last_raw = raw_response
last_terminal_state = terminal_state
last_wallclock = session_wallclock_seconds
last_depth = subagent_max_depth
last_outcome_synthesised = t0_outcome_synthesised
final_action = action
# The terminal actions (SUCCESS, END_CYCLE, EXHAUSTED) skip the
# loop entirely. The non-terminal actions enter the while loop.
while action not in (EscAction.SUCCESS, EscAction.END_CYCLE, EscAction.EXHAUSTED):
if action == EscAction.RETRY_POST_FETCH:
# One re-fetch of the head_sha; no worker re-run, no
# tier advance. Re-decide.
head_sha_advanced = _compute_head_sha_tristate(cfg, pr_number, pre_sha)
pr_state = _fetch_pr_state(cfg, pr_number)
# R3.7 post-push CI verification — the prior decide()
# returned RETRY_POST_FETCH because head_sha_advanced was
# None (post-session fetch failed). If the re-fetch now
# succeeds with head_sha_advanced=True, the verifier
# finally has a SHA to poll CI against. Same idempotency
# contract as the other callsites: returns unchanged
# when the conditions for verification aren't met.
last_parsed = _verify_post_push_ci(
cfg,
pr_number,
last_parsed,
head_sha_advanced,
)
action = _implementer_escalation.decide(
parsed_json=last_parsed,
terminal_state=last_terminal_state,
head_sha_advanced=head_sha_advanced,
pr_state=pr_state,
transport_retries_used=transport_retries,
current_tier=current_tier,
max_tier=_max_tier_for_cycle(),
)
final_action = action
continue
if action == EscAction.RETRY_SAME_TIER:
transport_retries += 1
_implementer_escalation.sleep_for_retry(transport_retries)
# No claim refresh, no worktree reset — this is a
# same-tier retry by design (transport-class failure).
new_session = _run_worker_at_tier(
cfg,
_work_group_for_item(item, resolved_group_name),
item,
current_tier,
tag=_tag_for_item(item, resolved_group_name),
on_poll=heartbeat_callback,
redact_values=[cfg.token] if getattr(cfg, "token", None) else [],
)
last_parsed = new_session.parsed_json
last_terminal_state = _terminal_state_from_session(new_session)
last_parsed, last_outcome_synthesised = _synthesize_outcome_if_missing(
last_parsed,
last_terminal_state,
)
last_raw = new_session.raw_response
last_wallclock = new_session.wallclock_seconds
last_depth = new_session.subagent_max_depth
else:
# ESCALATE: advance tier, refresh claim, reset worktree,
# apply new label, run worker.
current_tier += 1
transport_retries = 0
if not cfg.dry_run:
try:
_implementer_label_state.apply_attempt_label(
cfg,
pr_number,
current_tier,
)
except Exception as exc:
_logger.warning(
"apply_attempt_label(%d) failed mid-escalation "
"for #%s (continuing escalation without "
"per-tier label observability): %s",
current_tier,
pr_number,
exc,
)
# Refresh the visible TTL comment so the merge-driver
# sweep's expiry math advances. The agent-side
# claim_pr.ts release between sessions briefly drops
# the label; the race window is small and the
# implementer dispatcher's single-instance lock makes
# a sibling-dispatcher takeover practically impossible
# — see plan v2 §"Concurrency" and the legacy-removal
# milestone for future hardening.
try:
_claim_runtime.claim_pr(
pr_number,
cfg,
driver_name=DRIVER_NAME,
)
except Exception as exc:
_logger.warning(
"claim refresh between tiers failed for #%s "
"(escalation continues; merge-driver sweep "
"remains the safety net): %s",
pr_number,
exc,
)
_reset_worktree_to_pinned_sha(clone_handle)
new_session = _run_worker_at_tier(
cfg,
_work_group_for_item(item, resolved_group_name),
item,
current_tier,
tag=_tag_for_item(item, resolved_group_name),
on_poll=heartbeat_callback,
redact_values=[cfg.token] if getattr(cfg, "token", None) else [],
)
last_parsed = new_session.parsed_json
last_terminal_state = _terminal_state_from_session(new_session)
last_parsed, last_outcome_synthesised = _synthesize_outcome_if_missing(
last_parsed,
last_terminal_state,
)
last_raw = new_session.raw_response
last_wallclock = new_session.wallclock_seconds
last_depth = new_session.subagent_max_depth
head_sha_advanced = _compute_head_sha_tristate(cfg, pr_number, pre_sha)
pr_state = _fetch_pr_state(cfg, pr_number)
# R3.7 post-push CI verification — see comment in initial-
# attempt site above. Each tier's worker session may push,
# so each tier's outcome needs the same verification.
last_parsed = _verify_post_push_ci(
cfg,
pr_number,
last_parsed,
head_sha_advanced,
)
action = _implementer_escalation.decide(
parsed_json=last_parsed,
terminal_state=last_terminal_state,
head_sha_advanced=head_sha_advanced,
pr_state=pr_state,
transport_retries_used=transport_retries,
current_tier=current_tier,
max_tier=_max_tier_for_cycle(),
)
attempts.append(
_record_attempt(
current_tier,
last_parsed,
last_raw,
last_terminal_state,
last_wallclock,
last_depth,
action_str=action.value,
outcome_synthesised=last_outcome_synthesised,
)
)
final_action = action
# End-of-cycle cleanup: clone handle, sentinel, attempt labels.
cleanup_attempted, cleanup_error = _cleanup_clone_handle(
cfg,
item,
context_dict,
)
# Strict-walk policy (run-15 fix, 2026-05-16): the
# ``auto/last-attempt-tier-N`` label is the cross-cycle handoff
# that lets the NEXT dispatcher cycle skip the estimator and
# escalate deterministically (``_read_start_tier_from_labels``).
# Earlier behaviour cleared the label unconditionally at every
# cycle end, which broke that handoff and left every fresh cycle
# asking the estimator from scratch — the exact pathway that
# produced the run-15 doom spiral on PR #30 (estimator re-picked
# tier-min on three consecutive cycles because no label persisted
# to tell it tier-min had already failed).
#
# Only SUCCESS warrants clearing — the PR is done, labels are no
# longer informative. ESCALATE, END_CYCLE, EXHAUSTED, and the
# RETRY_* in-flight actions all keep the label so the next cycle
# picks up the deterministic walk where this one left off.
# EXHAUSTED specifically: keeping the label flags the PR as
# "tried at the ladder ceiling" so an operator scanning the UI
# sees the terminal state without having to read the per-cycle
# status comment.
if (
not cfg.dry_run
and final_action == _implementer_escalation.EscalationAction.SUCCESS
):
try:
_implementer_label_state.clear_attempt_labels(cfg, pr_number)
except Exception as exc:
_logger.warning(
"clear_attempt_labels failed for #%s: %s",
pr_number,
exc,
)
return {
"escalation_enabled": True,
"final_action": final_action.value,
"final_tier": current_tier,
"attempts": attempts,
"cleanup_attempted": cleanup_attempted,
"cleanup_error": cleanup_error,
"work_group_name": resolved_group_name,
}
def _work_group_for_item(item: dict[str, Any], group_name: str) -> Any:
"""Look up a :class:`_dispatch.WorkGroup` by name so the
escalation loop's worker-spawn helper has the same group object
that ``dispatch_one`` used for Tier 0. Falls back to the first
PR-shaped group on miss — the escalation loop only runs for
PR-shaped items, so this fallback is a defensive safety net.
"""
for g in WORK_GROUPS:
if g.name == group_name:
return g
for g in WORK_GROUPS:
if g.item_kind == "pr":
return g
return WORK_GROUPS[0]
def _tag_for_item(item: dict[str, Any], group_name: str) -> str:
"""Tag used for the OpenCode session for tier > 0 attempts.
Mirrors ``_dispatch_runtime._tag_for`` but accessible from
inside ``dispatch_implementer.py`` without re-importing the
helper. Kept lockstep with that function — both produce
``AUTO-IMP-PR-{number}`` for PR work.
"""
group = _work_group_for_item(item, group_name)
number = int(item.get("number") or 0)
suffix = "PR" if group.item_kind == "pr" else "ISSUE"
return f"{group.tag_prefix}-{suffix}-{number}"
def _dispatch_post_session_action(
cfg: Any,
item: dict[str, Any],
parsed_json: dict[str, Any] | None,
raw_response: str,
terminal_state: str,
*,
session_context: Any | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Route to the legacy or the escalation-aware post-session
action based on the runtime flag.
Checked at CALL TIME (not import time) so test code can
monkeypatch the env var per-test and toggle the path. The
fallback to the legacy path is unconditional when the flag is
OFF, preserving byte-equivalence with the pre-feature build.
Estimator-cache invalidation runs BEFORE the inner action so
that even if the inner action raises, the cache is already
cleared and the next cycle gets a fresh estimate (rather than
serving a now-disproven cached tier across the doom-loop window
that would otherwise extend to the cache TTL).
"""
_invalidate_estimator_cache_on_failure(item, parsed_json, terminal_state)
if _is_escalation_enabled():
return _post_session_action_with_escalation(
cfg,
item,
parsed_json,
raw_response,
terminal_state,
session_context=session_context,
**kwargs,
)
return _post_session_action(
cfg,
item,
parsed_json,
raw_response,
terminal_state,
session_context=session_context,
**kwargs,
)
# Worker outcomes the dispatcher classifies as success. Mirrors
# ``_implementer_escalation.SUCCESS_OUTCOMES`` but keeps the cache
# invalidation independent — we WANT to invalidate on ANY non-
# success outcome (failure, unresolved, rebase-failed, timeout,
# transport-error, unknown) so the estimator gets re-asked next
# cycle with the now-updated attempt-history digest.
_ESTIMATOR_CACHE_SUCCESS_OUTCOMES = frozenset({"resolved"})
def _invalidate_estimator_cache_on_failure(
item: dict[str, Any],
parsed_json: dict[str, Any] | None,
terminal_state: str,
) -> None:
"""Drop the estimator cache entry for this PR when the cycle's
worker session did NOT succeed.
Without this, a stuck PR whose ``auto/last-attempt-tier-N``
label fails to land (run-15-style failure mode) would re-serve
the same cached tier recommendation across cycles until the
cache TTL (default 1 h) expired — amplifying the very doom-loop
the estimator's step 2a cross-cycle-memory constraint exists to
defend against. By invalidating on failure, the next cycle's
estimator call sees the new attempt-history digest in the
prompt and (per step 2a) recommends a strictly-higher tier.
Safe to call for non-PR items (issues): the cache key helper
returns ``None`` for items with no ``head`` dict, so the lookup
bails before touching the cache.
"""
pr_number = _estimator_cache_pr_key(item)
if pr_number is None:
return
outcome: str | None = None
if isinstance(parsed_json, dict):
v = parsed_json.get("outcome")
if isinstance(v, str):
outcome = v
succeeded = (
terminal_state == "completed" and outcome in _ESTIMATOR_CACHE_SUCCESS_OUTCOMES
)
if succeeded:
return
dropped = _estimator_cache_drop_pr(pr_number)
if dropped:
_logger.info(
"estimator cache invalidated for PR #%s "
"(terminal_state=%s outcome=%s) — next cycle will "
"re-estimate with updated attempt-history digest",
pr_number,
terminal_state,
outcome,
)
# R3 (2026-05-17): each implementer WorkGroup carries
# ``requires_worker_agent_override=True`` so the dispatcher loop
# raises if ``_implementation_prompt_dispatch`` ever fails to stash
# the resolved per-tier agent on the item context. The static
# ``worker_agent="task-implementor-tier-0"`` here is a documented
# fallback that should NEVER trigger in production — the loud-fail
# guard exists to catch a regression that would silently mis-route
# every cycle to tier 0.
WORK_GROUPS = [
_dispatch.WorkGroup(
name="failing_ci_pr",
script_name="list_prs_ci_failing",
item_kind="pr",
claim_kind=CLAIM_KIND,
worker_agent="task-implementor-tier-0",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
requires_worker_agent_override=True,
),
_dispatch.WorkGroup(
name="request_changes_pr",
script_name="list_prs_changes_requested",
item_kind="pr",
claim_kind=CLAIM_KIND,
worker_agent="task-implementor-tier-0",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
requires_worker_agent_override=True,
),
_dispatch.WorkGroup(
name="new_issue",
script_name="list_issues",
item_kind="issue",
claim_kind=None,
worker_agent="task-implementor-tier-0",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
requires_worker_agent_override=True,
),
]
def load_config(*, dry_run: bool = False) -> Any:
token = _dispatch.load_secret("FORGEJO_PAT", "GITEA_TOKEN")
return _dispatch.DispatchConfig(
token=token,
forgejo_url=_dispatch.derive_forgejo_url(),
owner=os.environ.get("FORGEJO_OWNER", _dispatch.REPO_OWNER),
repo=os.environ.get("FORGEJO_REPO", _dispatch.REPO_NAME),
server_url=os.environ.get(
"OPENCODE_SERVER_URL", "http://127.0.0.1:4096"
).rstrip("/"),
lock_path=_dispatch.resolve_lock_or_heartbeat(
"IMPLEMENTER_DISPATCHER_LOCK_PATH", "implementer-dispatcher.lock"
),
heartbeat_path=_dispatch.resolve_lock_or_heartbeat(
"IMPLEMENTER_DISPATCHER_HEARTBEAT_PATH",
"implementer-dispatcher.heartbeat",
),
cycle_interval_seconds=int(
os.environ.get("IMPLEMENTER_DISPATCHER_CYCLE_SECONDS", "120")
),
max_items_per_cycle=int(
os.environ.get("IMPLEMENTER_DISPATCHER_MAX_ITEMS_PER_CYCLE", "1")
),
worker_timeout_seconds=int(
os.environ.get("IMPLEMENTER_DISPATCHER_WORKER_TIMEOUT_SECONDS", "7200")
),
claim_ttl_seconds=int(
os.environ.get("IMPLEMENTER_DISPATCHER_CLAIM_TTL_SECONDS", "7200")
),
api_retries=int(os.environ.get("IMPLEMENTER_DISPATCHER_API_RETRIES", "3")),
request_timeout_s=int(
os.environ.get("IMPLEMENTER_DISPATCHER_REQUEST_TIMEOUT_S", "30")
),
script_timeout_seconds=int(
os.environ.get("IMPLEMENTER_DISPATCHER_SCRIPT_TIMEOUT_SECONDS", "120")
),
table_name="dispatch_implementer_cycles",
dry_run=dry_run,
cycle_failure_budget=int(
os.environ.get("IMPLEMENTER_DISPATCHER_CYCLE_FAILURE_BUDGET", "5")
),
linked_issue_policy=_review_fetch.normalize_linked_issue_policy(
os.environ.get("IMPLEMENTER_DISPATCHER_LINKED_ISSUE_POLICY"),
),
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="run one cycle and exit")
parser.add_argument("--status", action="store_true", help="print config and exit")
parser.add_argument(
"--dry-run",
action="store_true",
help="claim nothing and do not dispatch",
)
args = parser.parse_args()
_dispatch._configure_logging("IMPLEMENTER_DISPATCHER_LOG_LEVEL")
cfg = load_config(dry_run=args.dry_run)
if args.status:
_dispatch.json_line(_dispatch.status_payload(cfg, driver_name=DRIVER_NAME))
return 0
if not args.dry_run:
# Worktree janitor (R3, 2026-05-16): sweep stale + corrupted
# implementer worktrees BEFORE the main loop runs a new
# cycle. Prior runs that SIGTERM-terminated mid-session
# leave orphaned worktree dirs whose mirror bookkeeping
# collides with the next ``git worktree add``; the janitor
# + the in-prepare auto-prune-on-failure together close
# that loop. See PR #29's 4-orphan accumulation in
# run-15/16/17/18 for the live evidence.
try:
_pr_clone.prune_orphan_worktrees(cfg, kind="implementer")
except Exception as exc: # noqa: BLE001
_logger.warning(
"implementer worktree janitor raised at startup; continuing: %s: %s",
type(exc).__name__,
exc,
)
# Block-store janitor: drop every expired row from the
# cross-process content block store. Best-effort, never
# raises.
try:
removed = _block_store.janitor()
if removed:
_logger.info(
"block store janitor: removed %s expired rows at startup",
removed,
)
except Exception as exc: # noqa: BLE001
_logger.warning(
"block store janitor raised at startup; continuing: %s: %s",
type(exc).__name__,
exc,
)
if args.once:
_dispatch.json_line(
_dispatch.run_one_cycle(
cfg,
WORK_GROUPS,
driver_name=DRIVER_NAME,
sweep_claim_kind=CLAIM_KIND,
)
)
return 0
_dispatch.run_outer_loop(
cfg,
WORK_GROUPS,
driver_name=DRIVER_NAME,
sweep_claim_kind=CLAIM_KIND,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())