0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3966 lines
163 KiB
Python
3966 lines
163 KiB
Python
#!/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())
|