Files
cleveragents-core/tools/dispatch_implementer.py
T
drew ea4a96aad6 feat(auto-agents): worktree hygiene — startup janitor + retry-on-failure
Live evidence on PR #29 (runs 15-18): 4 implementer worktrees
accumulated in ``/tmp/cleveragents-implementer-worktrees/``, one
with a corrupted ``.git`` link (``fatal: not a git repository``).
Root cause: every SIGTERM-induced dispatcher restart leaves the
in-flight cycle's worktree orphaned, and the mirror's
``worktrees/<name>/`` bookkeeping survives without the directory.
The next cycle's ``git worktree add`` against the same mirror can
then fail with "already exists" or unhelpful path collisions.

Two fixes that together close the loop:

1. **Startup janitor** (``_pr_clone.prune_orphan_worktrees``): scans
   the per-kind worktree base on dispatcher startup and removes
   any dir matching the canonical ``pr-{N}-{kind}-{hex-tag}``
   shape that is EITHER older than the OpenCode worker ceiling
   (default 30 min — longer than any possible in-flight cycle)
   OR has a missing / zero-byte ``.git`` link (definitionally
   corrupted). Removes the dir AND the mirror's ``worktree``
   bookkeeping. Idempotent. Skips operator scratch dirs that
   don't match the canonical name. Disable via
   ``DISPATCHER_WORKTREE_JANITOR_DISABLE=1``. Called once at the
   top of both ``dispatch_review.main`` and
   ``dispatch_implementer.main``.

2. **Retry-on-failure** in ``prepare_pr_worktree``: when
   ``git worktree add`` fails the first time, run
   ``git worktree prune`` to clear stale mirror bookkeeping, force-
   remove the target path if present, and retry exactly once.
   This rescues cycles whose janitor-min-age cushion missed a
   fresh orphan from a very-recent SIGTERM.

Coverage: 13 new tests in ``test_pr_clone_janitor.py`` (recent vs
stale removal, corruption detection regardless of age, non-canonical
name safety, idempotency, disable env, ``git worktree remove``
call count). Full auto_agents suite: 2059 passing (+13 vs prior
commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:42:06 -04:00

3199 lines
133 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Deterministic dispatcher for implementation workers.
The dispatcher preserves the priority order originally established by
the (now-decommissioned) ``implementation-supervisor.md`` LLM agent:
fix failing PRs first, then PRs with unaddressed review feedback, then
new issue work. The worker remains the LLM boundary; Python owns
queueing, PR claims, watchdogs, and telemetry. The legacy supervisor
agent was deleted on 2026-05-09 — see ``CHANGELOG.md`` and
``docs/development/auto-agents-tier-2-3-plan.md`` for the rationale —
and this dispatcher is now the only orchestrator for implementer work.
Phase 2 (2026-05-09) adds optional pre-fetch parity with
``dispatch_review.py``:
- ``IMPLEMENTER_DISPATCHER_PREFETCH=1`` swaps the legacy title-only
prompt for the rich pre-fetched prompt assembled in
:mod:`_implementer_prompt`. The worker reads PR description, diff,
CI status, comments, active REQUEST_CHANGES reviews, linked issues,
and the Epic body without issuing a single in-session GET.
- ``IMPLEMENTER_DISPATCHER_PRECLONE=1`` (Phase 3) additionally
pre-clones the PR's HEAD into ``/tmp/cleveragents-implementer-worktrees/``
so the worker can ``edit`` against the worktree without spawning
``git-isolator-util``.
Both flags default to ``0`` (legacy behaviour) until a Phase 4
acceptance run flips them to default-on. The substrate is built; the
flags ungate adoption per the plan in
``docs/development/auto-agents-tier-2-3-plan.md``.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
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"
)
_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")
_logger = logging.getLogger("dispatch_implementer")
DRIVER_NAME = "dispatch_implementer.py"
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 _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 prompt carries
an explicit ``escalation_tier_hint: N`` line so the worker
jumps straight to the right model — 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.
"""
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)
# When in-cycle escalation is OFF or the item is not a PR there
# is no escalation context to manage and no ``release_claim_on_exit``
# directive to emit. The estimator flag still controls whether
# the worker should fall through to ``estimator-implementation``
# on the first attempt (G11 harvest 2026-05-15).
if not _is_escalation_enabled() or not is_pr_shape:
if estimator_enabled:
# OMIT the hint so ``tier-dispatcher`` runs the estimator
# and chooses an adaptive tier.
return base_prompt
# Default OFF: emit an explicit hint=0 so the worker passes
# it through and ``tier-dispatcher`` short-circuits to
# ``tier-0``. Byte-equivalent to today's flag-OFF prompt
# after the worker's no-hint→default-0 fallback is removed
# (see ``implementation-worker.md`` G11 edit).
return f"{base_prompt}\n\nescalation_tier_hint: `0`\n"
pr_number = int(item.get("number") or 0)
start_tier = _read_start_tier_from_labels(cfg, pr_number)
# Stash on the per-item context so the post-session action can
# initialise its loop counter without re-fetching labels.
# ``_prefetch_prompt`` already places ``_dispatcher_implementer_context``
# on the item; this just adds a key. Defensive against unknown
# work groups whose prompt branches did not populate the
# context (the ``_prefetch_prompt`` fallback path).
context = item.get("_dispatcher_implementer_context")
if not isinstance(context, dict):
context = {}
item["_dispatcher_implementer_context"] = context
context["start_tier"] = start_tier
# Tell the worker to skip its session-end claim release.
# When escalation is on, the dispatcher holds the claim across
# all tier attempts in this cycle and the dispatch_one finally
# block does the actual release. Without this directive, the
# worker's release between tiers leaves a 13 s window where
# another driver could grab the PR. See plan #5.
extras = ["release_claim_on_exit: false"]
if start_tier > 0:
# Cross-cycle resumption: ``auto/last-attempt-tier-N`` labels
# name the next tier explicitly. The estimator cannot override
# label-driven resumption — even when ``estimator_enabled`` is
# True the hint is emitted so the worker resumes at the
# correct tier.
extras.append(f"escalation_tier_hint: `{start_tier}`")
elif not estimator_enabled:
# Estimator OFF on a true first attempt (no resumption labels):
# emit the explicit hint=0 fallback so the worker short-circuits
# to ``tier-0`` — byte-for-byte the legacy behaviour.
extras.append("escalation_tier_hint: `0`")
# else: estimator ON, first attempt — OMIT the hint so the worker
# falls through to ``estimator-implementation``.
final_prompt = f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
# 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 final_prompt
# ─── 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}; "
f"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) ──────────────────────────────────
def _fetch_pr_state(cfg: Any, pr_number: int) -> str:
"""Best-effort GET of the PR state ("open" / "closed" / "merged").
The escalation loop calls this between tier attempts so the
predicate (:func:`_implementer_escalation.decide`) can end the
cycle early when the PR is closed or merged by a human mid-cycle.
On any failure (dry-run, transient 5xx, network), this returns
``"open"`` — the conservative default that lets the loop continue
rather than spuriously ending the cycle on a transient fetch
blip. The plan's tier-stable class names this explicitly.
"""
if cfg.dry_run or pr_number <= 0:
return "open"
try:
response = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr_number)}", cfg
)
except Exception as exc:
_logger.warning(
"PR state fetch failed for #%s (treating as 'open'): %s",
pr_number, exc,
)
return "open"
if int(response.get("status") or 0) != 200:
return "open"
body = response.get("body") or {}
if not isinstance(body, dict):
return "open"
state = body.get("state")
if isinstance(state, str) and state:
# Forgejo exposes "merged" via the boolean `merged` flag on
# state=closed PRs; honour that distinction so the predicate
# can map both to tier-stable without false-negatives.
if state == "closed" and body.get("merged") is True:
return "merged"
return state
return "open"
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
def _reset_worktree_to_pinned_sha(handle: Any) -> bool:
"""Reset the pre-cloned worktree to the SHA captured at
prefetch time so a subsequent tier starts from the same baseline
Tier 0 saw.
PD P0 (plan v2 critique): a plain ``git reset --hard HEAD`` is
ambiguous if the worker pushed during Tier N — ``HEAD`` may
have advanced via the worker's session-end ``git fetch``. The
pinned ``handle.head_sha`` is the prefetch-time snapshot stored
on the :class:`_pr_clone.WorktreeHandle`. Reset target is that
SHA explicitly.
Returns ``True`` on success, ``False`` on any subprocess failure
(logged WARNING; escalation continues — the next session's
``implementer-workspace.py discover`` will see the vanished /
dirty worktree and fall through to ``git-isolator-util``, the
legacy pre-pre-clone code path).
Run-11 deep inspection: the prior worker session can ``rm -rf``
its own worktree (the task-implementor prompt at one point
explicitly instructed it to — fixed in the same commit as this
function). Defend against that and other state-corruption cases:
- Missing worktree dir: log clearly and return False without
shelling out to ``git`` against a non-existent path.
- Stale ``.git/index.lock`` from a SIGKILL'd worker git op:
remove it so the reset isn't blocked by an orphan lock.
- On ``git`` failure, surface the captured stderr in the
warning — the bare ``CalledProcessError.__str__`` only shows
the exit code, which is useless for diagnosis.
Note: the remote branch may carry commits the worker pushed in
Tier N. The plan's silent-worst-case rule
(``head_sha_advanced=True`` + non-success outcome → END_CYCLE)
short-circuits that case before we reach this reset.
"""
import subprocess
from pathlib import Path as _Path
if handle is None:
return False
path = getattr(handle, "path", None)
pinned_sha = getattr(handle, "head_sha", None)
if not path or not pinned_sha:
return False
sha_short = str(pinned_sha)[:12]
if not _Path(path).is_dir():
_logger.warning(
"worktree disappeared before reset for SHA %s at %s"
"the prior worker session likely deleted it. Escalation "
"will continue; the next session's "
"``implementer-workspace.py discover`` will fall through "
"to ``git-isolator-util`` for a fresh clone.",
sha_short, path,
)
return False
# Stale-lock cleanup. A SIGKILL'd or crashed git op in the
# previous session can leave ``.git/<name>.lock`` files behind
# (``index.lock`` is the common one, but ``HEAD.lock``,
# ``MERGE_MSG.lock``, ``ORIG_HEAD.lock`` are also possible),
# which then blocks every subsequent git operation with
# ``Unable to create '...lock'``. The locks have no legitimate
# concurrent owner here (the worker session has already
# terminated by the time the dispatcher is between tiers), so
# removing them is safe and matches what an operator would do
# by hand. The glob covers every ``*.lock`` under the worktree's
# ``.git/`` rather than just ``index.lock`` so any combination
# of crashed git ops leaves a clean slate for the reset.
try:
git_dir = _Path(path) / ".git"
if git_dir.is_dir():
for lock in git_dir.glob("*.lock"):
if lock.is_file():
lock.unlink()
_logger.info(
"removed stale .git/%s at %s before reset",
lock.name, path,
)
except OSError:
pass
try:
subprocess.run(
["git", "-C", str(path), "reset", "--hard", str(pinned_sha)],
check=True, capture_output=True, timeout=30, text=True,
)
subprocess.run(
["git", "-C", str(path), "clean", "-xfdq"],
check=True, capture_output=True, timeout=30, text=True,
)
return True
except subprocess.CalledProcessError as exc:
# ``CalledProcessError.__str__`` only shows the exit code —
# include the actual stderr so the operator can act on it
# (vanished SHA vs. lock contention vs. permissions issue,
# etc.).
_logger.warning(
"worktree reset to pinned SHA %s failed at %s: "
"exit=%s stderr=%r; escalation continues",
sha_short, path, exc.returncode,
(exc.stderr or "").strip()[:400],
)
return False
except subprocess.SubprocessError as exc:
# Timeout / missing binary / etc. — same fail-soft policy.
_logger.warning(
"worktree reset to pinned SHA %s raised %s at %s; "
"escalation continues",
sha_short, type(exc).__name__, path,
)
return False
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.
valid_tier_range = range(len(_implementer_label_state.ATTEMPT_TIER_LABELS))
highest_labelled = -1
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
try:
n = int(name.rsplit("-", 1)[-1])
except ValueError:
continue
if n not in valid_tier_range:
_logger.warning(
"PR #%s carries out-of-range attempt label %r "
"(valid: %s); skipping for start-tier seed",
pr_number, name, list(valid_tier_range),
)
continue
if n > highest_labelled:
highest_labelled = n
if highest_labelled < 0:
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_hint`` line is added cleanly without risking
a stale inherited hint from cross-cycle resumption. Tier 1+
append the explicit hint. Tier 0 emission depends on the G11
estimator flag: with the flag OFF (default) an explicit
``escalation_tier_hint: `0` `` line is emitted to preserve the
legacy short-circuit; with the flag ON the line is omitted so
``tier-dispatcher`` runs the estimator instead.
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)
# The escalation loop only spawns subsequent tier sessions when
# ``_is_escalation_enabled()`` is True and the item is
# PR-shaped. In every case where this helper runs the
# release-skip directive applies, so include it unconditionally
# — the dispatcher holds the claim across the whole cycle.
extras = ["release_claim_on_exit: false"]
if tier > 0:
extras.append(f"escalation_tier_hint: `{tier}`")
elif not _is_implementer_estimator_enabled():
# G11 (2026-05-15): emit the explicit hint=0 fallback so the
# worker short-circuits to ``tier-0`` when the estimator flag
# is OFF. The worker no longer defaults to ``0`` on its own.
extras.append("escalation_tier_hint: `0`")
# else (tier == 0 AND estimator enabled): omit the hint so the
# worker falls through to ``estimator-implementation``.
return f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
def _per_tier_worker_timeout(cfg: Any, tier: int) -> int:
"""Resolve the worker timeout for ``tier``.
Per-tier override via ``IMPLEMENTER_DISPATCHER_WORKER_TIMEOUT_TIER_{N}_SECONDS``;
falls back to ``cfg.worker_timeout_seconds`` (the global
setting). Empirical observation (2026-05-13): the higher tier
slots (``tier-1``, ``tier-2``) historically ran significantly
slower than the default slot (``tier-0``) and the global 1800s
limit forced repeated timeouts on PR #30 attempts 4-5 in the
live test. Recommended values when the global is set to a
Tier-0-friendly default of 900s: Tier 0=900s, Tier 1=1800s,
Tier 2=3600s. The actual model behind each slot is configured
in ``.opencode/models/tiers.yaml``; the per-tier timeout knob
is slot-based, not model-based, so it stays correct across
model swaps.
The override is read at session-spawn time so an operator can
bump the value without restarting the dispatcher (next worker
invocation picks it up).
"""
env_name = f"IMPLEMENTER_DISPATCHER_WORKER_TIMEOUT_TIER_{int(tier)}_SECONDS"
raw = os.environ.get(env_name)
if raw:
try:
return max(60, int(raw))
except ValueError:
_logger.warning(
"%s=%r is not an int; falling back to global timeout",
env_name, raw,
)
return int(cfg.worker_timeout_seconds)
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`.
"""
prompt = _build_prompt_for_tier(cfg, item, group, tier)
return _opencode_worker.run_session_blocking(
server_url=cfg.server_url,
agent=group.worker_agent,
tag=tag,
prompt=prompt,
timeout_seconds=_per_tier_worker_timeout(cfg, tier),
on_poll=on_poll,
redact_values=redact_values,
)
def _terminal_state_from_session(session: Any) -> str:
"""Mirror dispatch_one's terminal_state derivation for a session
that the escalation loop spawned (Tier 1+). ``completed`` for the
happy path, the raw ``status`` otherwise (``timeout`` /
``transport-error``)."""
return "completed" if session.status == "completed" else session.status
# Map worker terminal_state → synthesized outcome string when the
# worker emitted no parseable JSON. The escalation predicate's
# UNKNOWN bucket (1 retry then escalate) is the right fallback for
# "we don't know what happened", but the more common case in
# production is "worker gave up and narrated a failure without
# emitting the contract JSON" (gpt-5-mini's failure mode on PR #30,
# Run 1). Synthesizing a concrete outcome lets ``decide()`` route
# to ESCALATE-as-competence-failure (skip the wasted same-tier
# retry) and gives the status-comment fingerprint a non-empty
# reason string. The synthesis is gated behind escalation so the
# flag=0 path is byte-equivalent to the pre-feature build.
_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()))
if not cfg.dry_run:
# 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,
)
head_sha_advanced = _compute_head_sha_tristate(cfg, pr_number, pre_sha)
pr_state = _fetch_pr_state(cfg, pr_number)
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)
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)
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,
)
if not cfg.dry_run:
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.
"""
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,
)
WORK_GROUPS = [
_dispatch.WorkGroup(
name="failing_ci_pr",
script_name="list_prs_ci_failing",
item_kind="pr",
claim_kind=CLAIM_KIND,
worker_agent="implementation-worker",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
),
_dispatch.WorkGroup(
name="request_changes_pr",
script_name="list_prs_changes_requested",
item_kind="pr",
claim_kind=CLAIM_KIND,
worker_agent="implementation-worker",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
),
_dispatch.WorkGroup(
name="new_issue",
script_name="list_issues",
item_kind="issue",
claim_kind=None,
worker_agent="implementation-worker",
tag_prefix="AUTO-IMP",
prompt_factory=_implementation_prompt_dispatch,
post_session_action=_dispatch_post_session_action,
),
]
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,
)
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())