b154d48027
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>
1905 lines
76 KiB
Python
1905 lines
76 KiB
Python
#!/usr/bin/env python3
|
||
"""Deterministic dispatcher for implementation workers.
|
||
|
||
The dispatcher preserves the priority order originally established by
|
||
the (now-decommissioned) ``implementation-supervisor.md`` LLM agent:
|
||
fix failing PRs first, then PRs with unaddressed review feedback, then
|
||
new issue work. The worker remains the LLM boundary; Python owns
|
||
queueing, PR claims, watchdogs, and telemetry. The legacy supervisor
|
||
agent was deleted on 2026-05-09 — see ``CHANGELOG.md`` and
|
||
``docs/development/auto-agents-tier-2-3-plan.md`` for the rationale —
|
||
and this dispatcher is now the only orchestrator for implementer work.
|
||
|
||
Phase 2 (2026-05-09) adds optional pre-fetch parity with
|
||
``dispatch_review.py``:
|
||
|
||
- ``IMPLEMENTER_DISPATCHER_PREFETCH=1`` swaps the legacy title-only
|
||
prompt for the rich pre-fetched prompt assembled in
|
||
:mod:`_implementer_prompt`. The worker reads PR description, diff,
|
||
CI status, comments, active REQUEST_CHANGES reviews, linked issues,
|
||
and the Epic body without issuing a single in-session GET.
|
||
- ``IMPLEMENTER_DISPATCHER_PRECLONE=1`` (Phase 3) additionally
|
||
pre-clones the PR's HEAD into ``/tmp/cleveragents-implementer-worktrees/``
|
||
so the worker can ``edit`` against the worktree without spawning
|
||
``git-isolator-util``.
|
||
|
||
Both flags default to ``0`` (legacy behaviour) until a Phase 4
|
||
acceptance run flips them to default-on. The substrate is built; the
|
||
flags ungate adoption per the plan in
|
||
``docs/development/auto-agents-tier-2-3-plan.md``.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
import os
|
||
import sys
|
||
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 1–3 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())
|