Files
cleveragents-core/tools/dispatch_implementer.py
T
drew b154d48027 feat(auto-agents): deterministic worker-side improvements
Four changes that move judgment off the LLM and into the dispatcher,
motivated by the live PR #30 escalation pilot (2026-05-12): Tier 0
spent 16 min and gave up on flaky-looking unrelated tests; Tier 2
spent 88 min discovering the PR was already correct and only needed
two compliance entries. Together these collapse the typical
"PR is correct, only needs compliance fixups" case from 88 min on
Kimi to ~5 min on gpt-5-mini at Tier 0.

- Outcome-JSON synthesis in dispatch_implementer
  (_synthesize_outcome_if_missing): synthesises a concrete outcome
  from terminal_state when the worker emits no contract JSON,
  routing the escalation predicate to ESCALATE instead of UNKNOWN.

- Diff-aware gate parser (_diff_aware_gate.py): pure-Python
  classifier that splits failing BDD scenarios into related vs.
  unrelated to the PR's changed files via a feature-stem heuristic.

- Flaky-test pre-flight (_implementer_gate_preflight.py): runs
  local_ci_gate.sh --fast twice and surfaces only the persistent
  failures. Off-by-default behind IMPLEMENTER_DISPATCHER_GATE_PREFLIGHT.

- Compliance gap detector (_implementer_compliance.py): deterministic
  check of CHANGELOG / CONTRIBUTORS / commit-footer / worktree-clean
  state. Result is embedded as a "Compliance gap report" stanza so
  the agent fills in known gaps instead of discovering them.

Both prompt stanzas are appended via _append_deterministic_stanzas,
flag-gated, skipped in dry-run, and skipped when no preclone exists.
Flag-off path is byte-equivalent to the pre-feature build.

Suite: 1382 passed, 3 skipped (+59 new tests over 4 modules).

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

1905 lines
76 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")
_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_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_gate_preflight = _load_sibling(
"_implementer_gate_preflight", "_implementer_gate_preflight.py"
)
_diff_aware_gate = _load_sibling(
"_diff_aware_gate", "_diff_aware_gate.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-kimi`` / self-hosted Kimi-K2 GGUF Q2) 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 Kimi-K2's wallclock turns out
# to be operationally painful in practice).
#
# 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"
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_tier2_enabled() -> bool:
"""Return ``True`` when Tier 2 (``tier-kimi``) 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 Kimi-K2
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 _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)
# 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.
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,
)
except Exception as e: # noqa: BLE001 — best-effort
_logger.warning(
"PR context sentinel write failed for PR #%s: %s",
pr_number, e,
)
# Append deterministic-improvement stanzas (plan 2026-05-13).
# Both are gated and skipped in dry-run / when no worktree was
# materialised. Each appends a self-contained markdown section
# the worker can read without changing the existing prompt body.
text = _append_deterministic_stanzas(
text, cfg, item, group, result, clone_handle,
)
return text
def _append_deterministic_stanzas(
prompt: str,
cfg: Any,
item: dict[str, Any],
group: Any,
result: Any,
clone_handle: Any,
) -> str:
"""Append the gate-preflight + compliance-gap stanzas to the
prefetch prompt when their respective flags are on.
Both stanzas need the pre-cloned worktree's path — when the
preclone is disabled or failed, we skip silently (the worker
falls back to its in-session discovery flow). Both are off by
default so the legacy prompt is byte-equivalent for operators
who haven't opted 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 the compliance stanza reports
"all gaps closed" the agent's exit path is clean even if the
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
pr_number = int(item.get("number") or 0)
extras: list[str] = []
# ─── 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,
)
stanza = _diff_aware_gate.render_prompt_stanza(classification)
if stanza:
extras.append(stanza)
except Exception as exc:
_logger.warning(
"gate pre-flight failed for #%s "
"(continuing without the stanza): %s",
pr_number, exc,
)
# ─── Compliance gap detection (gated on escalation flag) ───
if _is_escalation_enabled():
try:
git_user_email = (
os.environ.get("GIT_USER_EMAIL")
or getattr(cfg, "git_user_email", "")
or ""
)
gaps = _implementer_compliance.check_compliance_gaps(
worktree_path, git_user_email,
)
stanza = _implementer_compliance.render_prompt_stanza(
gaps, pr_number=pr_number or None,
)
if stanza:
extras.append(stanza)
except Exception as exc:
_logger.warning(
"compliance gap detection failed for #%s "
"(continuing without the stanza): %s",
pr_number, exc,
)
if not extras:
return prompt
return prompt + "\n\n" + "\n\n".join(extras) + "\n"
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
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)
if not _is_escalation_enabled():
return base_prompt
is_pr_shape = isinstance(item.get("head"), dict)
if not is_pr_shape:
return base_prompt
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:
extras.append(f"escalation_tier_hint: `{start_tier}`")
return f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
# ─── 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,
) -> 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,
)
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)
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,
}
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 with a potentially-dirty
worktree, which is suboptimal but does not break the loop).
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
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
try:
subprocess.run(
["git", "-C", str(path), "reset", "--hard", str(pinned_sha)],
check=True, capture_output=True, timeout=30,
)
subprocess.run(
["git", "-C", str(path), "clean", "-xfdq"],
check=True, capture_output=True, timeout=30,
)
return True
except subprocess.SubprocessError as exc:
_logger.warning(
"worktree reset to pinned SHA %s failed at %s: %s; "
"escalation continues with current worktree state",
pinned_sha[:12] if pinned_sha else "<unknown>",
path, exc,
)
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 0
omits the hint line so the agent's template's default
``escalation_tier_hint: 0`` fallback fires (byte-equivalent to
the flag=0 prompt). Tier 1+ append the explicit hint.
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. ``escalation_tier_hint`` is added for tier > 0
# so the worker routes through the right ``tier-*`` selector.
extras = ["release_claim_on_exit: false"]
if tier > 0:
extras.append(f"escalation_tier_hint: `{tier}`")
return f"{base_prompt}\n\n" + "\n".join(extras) + "\n"
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.
"""
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=cfg.worker_timeout_seconds,
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 so an operator analysing the JSONL sink can distinguish
cycles where the worker explicitly reported failure from cycles
where the dispatcher synthesised a verdict.
"""
if isinstance(parsed_json, dict):
outcome = parsed_json.get("outcome")
if isinstance(outcome, str) and outcome:
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,
) -> 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,
)
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,
))
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
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, _ = _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, _ = _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,
))
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")
),
)
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 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())