Files
cleveragents-core/tools/implementer_pr_context.py
T
drew 3f12e4140c fix(auto-agents): R3.4 — cycle-cap signature + implementer sees full reviewer record
Two related fixes surfaced by the 2026-05-17 run-5 live observation:

1. **Cycle-cap signature now includes total_reviews count** so the
   reviewer's COMMENT-only path actually moves the signature. Before:
   the cap signature was ``sha + (approvals + has_active_RC +
   has_unaddressed_RC)`` — all three boolean axes ignore COMMENT
   reviews entirely. After: ``+ total_reviews`` term bumps on
   EVERY review submission. The ``data_complete=False → COMMENT
   downgrade`` path the reviewer takes in low-context cycles now
   reflects in the signature, so the cap stops firing falsely.

   Without this, run-5 observed 4 fresh PRs hitting count=5 in
   ~10 minutes — the reviewer was successfully posting reviews
   every cycle but the cap saw "no change" because COMMENT-only
   reviews don't bump approvals_count, has_active_RC, or
   has_unaddressed_RC.

2. **Implementer now sees ALL reviewer feedback**, not just active
   REQUEST_CHANGES. Before: ``fetch_pr_fix_context`` passed
   ``include_active_reviews=False`` so failing-CI PRs reached the
   implementer with zero reviewer data. ``fetch_request_changes_pr_context``
   only included active RC reviews — COMMENT-only feedback was
   invisible in both code paths.

   After: ``fetch_pr_fix_context`` also includes reviews, AND the
   fetcher now partitions reviews into TWO buckets — the existing
   ``request_changes_reviews`` (active blocking RC, unchanged
   semantic) and a new ``comment_reviews`` field carrying every
   non-dismissed non-RC review (COMMENT / APPROVE). Both are
   persisted in the PR-context sentinel and exposed via
   ``implementer_pr_context.py``'s ``comment_reviews`` field.

   New prompt section ``## Pre-fetched reviewer comments and
   approvals`` renders the comment_reviews bucket with author /
   event / commit / body / inline comments + a postscript marking
   them as ADVISORY (not blocking, unlike the existing RC section).

   This closes the architectural gap where the reviewer and
   implementer pools could work in silos on the same PR — the
   reviewer's substantive prose feedback now reaches the
   implementer regardless of which work-group routed it.

Files touched:
- tools/_pr_classification_cache.py — total_reviews in classify_pr +
  reactivity composite; docstring updated.
- tools/_implementer_prefetch.py — new comment_reviews +
  comment_reviews_completed fields; fetcher partitions reviews
  once; fetch_pr_fix_context now includes reviews.
- tools/_implementer_prompt.py — _build_comment_reviews_section;
  wired into prompt assembly between RC and PR-comments sections.
- tools/_pr_context_sentinel.py — comment_reviews in _to_dict.
- tools/implementer_pr_context.py — comment_reviews accessor for
  the worker's handoff read path.
- tests/auto_agents/test_pr_context_sentinel.py — expected_value_keys
  + fixture + round-trip test updated.
- .opencode/agents/estimator-implementation.md — restored canonical
  section header levels (####) for downstream test compatibility
  after R3.1 rewrite.

Full auto_agents suite: 2303 passing (+12 from this and adjacent
work, none broken).

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

519 lines
21 KiB
Python
Executable File

#!/usr/bin/env python3
"""Worker-side reader for the dispatcher's PR-context handoff sentinel.
What this is
------------
The dispatcher pre-fetches everything the implementer worker needs to
make a code change — PR description, full diff, CI status + per-check
detail, PR comments, active REQUEST_CHANGES reviews, linked issues,
parent Epic body — and writes the lot to a sentinel at
``/tmp/cleveragents-implementer-handoff/pr-{N}.json``. The dispatcher
ALSO embeds those sections in the wrapper session's user prompt, but
each ``task`` tool call inside OpenCode re-summarises the prompt for
the child agent — by the time the chain reaches ``task-implementor``
at depth 3, only the diff section reliably survives.
This script is what the implementer worker calls (via the
``implementer-pr-context`` skill) to re-read any prefetched section
on demand, regardless of how the prompt got mangled upstream. It is
the read half of :mod:`tools._pr_context_sentinel`.
Contract
--------
Subcommand ``read``:
``python3 tools/implementer_pr_context.py read --pr <N> --field <name>``
Reads the sentinel at ``/tmp/cleveragents-implementer-handoff/pr-{N}.json``,
pulls the requested field, prints it on stdout, and exits 0. The
``--field`` value selects which slice of the prefetched data:
- ``description`` — the PR body text (plain string)
- ``title`` — the PR title (plain string)
- ``metadata`` — JSON with work_type / work_group / head_sha /
head_ref / base_ref / pr_number / data_complete / error_kinds
- ``ci`` — JSON with the latest CI ``state`` plus ``checks[]``
(per-status detail, including ``target_url`` and any prefetched
failure-log excerpts)
- ``comments`` — JSON list of PR / issue comments (chronological).
For PRs this is the BOUNDED view: the most-recent N comments plus
every human/reviewer comment — the older bot ``**Implementation
Attempt**`` comments are rolled up into ``comments_digest`` instead.
- ``comments_digest`` — JSON dict: the deterministic attempt-history
digest (counts by tier / outcome, gates failing most, last
success) computed from the FULL PR comment list. ``{}`` when no
attempt comments were parsed.
- ``reviews`` — JSON list of active REQUEST_CHANGES reviews + their
inline comments (for ``request_changes_pr`` work only)
- ``issues`` — JSON list of linked issues (the ``Closes #N``
targets, with their bodies inlined)
- ``epic`` — JSON dict with the parent Epic body, or empty if no
Epic reference was found
- ``diff`` — the unified diff as plain text (between
``BEGIN_PR_DIFF`` / ``END_PR_DIFF`` markers in the original
prompt)
- ``compliance_gaps`` — JSON dict of the dispatcher's deterministic
PR Compliance Checklist scan (worktree_clean, changelog,
contributors, issues-closed footer). Empty stdout when the
dispatcher did not run the scan this cycle.
- ``gate_preflight`` — JSON dict of the heavy flaky-test gate
pre-flight (two ``local_ci_gate.sh --fast`` runs, classification
of persistent vs. flaky failures). Empty stdout when the
dispatcher did not run the pre-flight this cycle.
- ``all`` — the entire sentinel payload as JSON (useful for
debugging; ``task-implementor`` should pull narrowly)
The script emits one of three signals on stdout, all with exit
code 0:
- **Empty stdout** — the sentinel is missing, malformed, or the
dispatcher's fetch for this field did not complete (per the
matching ``*_completed`` flag). The worker should fall through
to its legacy curl-based GET against the live Forgejo API.
- ``null\\n`` — the dispatcher fetched successfully and
authoritatively confirmed absence (e.g. the PR has no Epic
reference). The worker should SKIP its legacy GET; there is
nothing to fetch.
- Any other content — the field's value (plain text for
description/title/diff/issue_body, pretty-printed JSON for
everything else). The worker should use it verbatim.
This three-case contract is how the worker distinguishes "the
dispatcher didn't try" (fall through) from "the dispatcher tried
and confirmed there is nothing" (don't re-curl). Conflating the
two — as the original ``or None`` projection did — caused the
worker to redundantly hit Forgejo for sections the dispatcher
had already verified empty.
Exit codes
----------
- 0: success (or empty stdout for "no data available" — see above).
- 2: usage error (bad / missing CLI arg, unknown ``--field``).
- 3: schema-version mismatch (a config issue the operator must
notice; the worker should treat this as "no data available" but
the non-zero code makes the situation visible in logs).
Allow-rule
----------
This script's path is whitelisted under
``python3 tools/implementer_pr_context.py *`` in the agent's
permission allowlist (see :file:`.opencode/agents/task-implementor.md`).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any
# Must match :data:`tools._pr_context_sentinel.SCHEMA_VERSION`.
EXPECTED_SCHEMA_VERSION = 1
_DEFAULT_HANDOFF_DIR = Path("/tmp/cleveragents-implementer-handoff")
def _handoff_dir() -> Path:
"""Resolve the on-disk directory. Mirrors
:func:`tools._pr_context_sentinel.handoff_dir`."""
return Path(
os.environ.get("IMPLEMENTER_DISPATCHER_PR_CONTEXT_DIR")
or str(_DEFAULT_HANDOFF_DIR)
)
def _handoff_path(pr_number: int) -> Path:
return _handoff_dir() / f"pr-{int(pr_number)}.json"
def _load(pr_number: int) -> tuple[dict[str, Any] | None, str | None]:
"""Read + parse + validate the sentinel.
Returns ``(payload, error)``:
- ``(payload, None)`` — sentinel exists, parses, schema matches.
- ``(None, None)`` — sentinel does not exist (normal: dispatcher
didn't prefetch, or already cleaned up).
- ``(None, error)`` — sentinel exists but is unusable (malformed,
schema mismatch). The caller emits the error to stderr.
"""
target = _handoff_path(pr_number)
if not target.exists():
return None, None
try:
raw = target.read_text(encoding="utf-8")
payload = json.loads(raw)
except (OSError, ValueError) as e:
return None, f"handoff read/parse failed at {target}: {e}"
if not isinstance(payload, dict):
return None, f"handoff at {target} is not a JSON object"
schema = payload.get("schema_version")
if schema != EXPECTED_SCHEMA_VERSION:
return None, (
f"handoff at {target} has schema_version={schema!r}, "
f"expected {EXPECTED_SCHEMA_VERSION}"
)
return payload, None
# Sentinel for "field is not present in this handoff" / "fetch failed
# upstream". Distinct from ``None`` so the worker contract can
# distinguish three cases:
#
# - ``_MISSING`` → emit NOTHING (empty stdout); worker falls through
# to its legacy GET. Used when the dispatcher didn't attempt this
# section (legacy prompt path) OR when the dispatcher attempted
# and failed (transient error reflected in the matching
# ``*_completed=False`` flag).
# - ``None`` → emit the JSON literal ``null`` (stdout = ``null\n``).
# Used when the dispatcher fetched successfully and confirmed
# absence (e.g. the PR has no Epic reference). The worker sees
# non-empty stdout and skips its legacy GET, treating "null" as
# the authoritative "no data" answer.
# - any other value → emit it; worker uses it verbatim.
#
# Without this distinction the worker re-curls the live Forgejo API
# every time the dispatcher confirmed an absence, defeating the
# whole point of the handoff.
_MISSING: Any = object()
def _gated_list(
payload: dict[str, Any], value_key: str, completed_key: str
) -> Any:
"""Three-case helper for list-valued fields. ``_MISSING`` when
``completed_key`` is False; the value (or ``[]`` if absent /
falsy) otherwise. Empty list serialises as ``[]\\n``, which
the worker reads as authoritative-empty (skip legacy GET)."""
if not payload.get(completed_key, True):
return _MISSING
return payload.get(value_key) or []
def _gated_text(
payload: dict[str, Any], value_key: str, completed_key: str
) -> Any:
"""Three-case helper for plain-text fields.
- completion flag False → ``_MISSING`` (worker falls through to
legacy GET).
- completion flag True + empty body → ``None`` (rendered as
``null\\n``; worker reads as authoritative-empty and skips
the legacy GET).
- completion flag True + non-empty body → the body verbatim.
The completion-flag-aware path is what lets a legitimately-empty
PR description (``description_completed=True``, ``description=""``)
cleanly signal "no description, don't re-fetch" rather than
forcing a redundant GET. Before the flag existed, every empty
description / title / diff / issue_body cost the worker a
redundant Forgejo round-trip.
"""
if not payload.get(completed_key, True):
return _MISSING
body = payload.get(value_key)
if not body:
return None
return body
def _project_field(payload: dict[str, Any], field: str) -> Any:
"""Return the slice of ``payload`` selected by ``--field``.
Implements the **three-case contract** the worker relies on:
- :data:`_MISSING` — the field is genuinely absent OR the
dispatcher's fetch for it did not complete successfully (per
the field's ``*_completed`` flag). :func:`_emit` renders this
as empty stdout; the worker falls through to its legacy GET.
- ``None`` — the dispatcher's fetch succeeded AND confirmed
the field is empty in a way the JSON schema can encode (e.g.
``epic=None`` means "PR has no parent Epic"). :func:`_emit`
renders this as ``null\\n``; the worker skips the legacy GET.
- any other value — the value itself, rendered verbatim (plain
text for ``description``/``title``/``diff``/``issue_body``,
pretty-printed JSON for the rest). Empty lists / empty dicts
ALSO fall through this case and render as ``[]\\n`` / ``{}\\n``
— they carry the "fetched, confirmed empty" semantics for
list/dict-valued fields.
The :data:`_MISSING` vs ``None`` vs value distinction is what
stops the worker from re-curling Forgejo for sections the
dispatcher has already confirmed are empty. Mapping all three
cases to ``None`` (the pre-2026-05-10 behavior) collapsed the
"didn't try" and "tried, confirmed empty" cases together and
every confirmed-empty section cost the worker a redundant GET.
"""
if field == "all":
return payload
if field == "description":
return _gated_text(payload, "description", "description_completed")
if field == "title":
return _gated_text(payload, "title", "title_completed")
if field == "diff":
return _gated_text(payload, "diff", "diff_completed")
if field == "issue_body":
return _gated_text(payload, "issue_body", "issue_body_completed")
if field == "metadata":
# The ``completion`` sub-object surfaces the per-section
# ``*_completed`` flags so an operator inspecting via
# ``--field metadata`` can see ALL fetcher state in one
# query without having to iterate every ``--field``. The
# worker's per-field reads still rely on the individual
# field projections — this is for human/operator triage.
#
# Enumerates payload keys (rather than hardcoding the
# name list) so a new ``*_completed`` flag added to the
# writer's ``COMPLETION_FLAG_NAMES`` tuple appears here
# automatically — keeps this reader script standalone
# (no dispatcher / prefetch imports) while still tracking
# writer-side additions without code changes. Stable
# sort order so two consecutive ``--field metadata``
# reads produce byte-identical output (worker / operator
# diff tooling depends on this).
completion = {
k.removesuffix("_completed"): payload.get(k, True)
for k in sorted(payload)
if k.endswith("_completed")
}
return {
"schema_version": payload.get("schema_version"),
"pr_number": payload.get("pr_number"),
"work_type": payload.get("work_type"),
"work_group": payload.get("work_group"),
"head_sha": payload.get("head_sha"),
"head_ref": payload.get("head_ref"),
"base_ref": payload.get("base_ref"),
"listing_title": payload.get("listing_title"),
"data_complete": payload.get("data_complete"),
"completion": completion,
"error_kinds": payload.get("error_kinds") or [],
"created_at": payload.get("created_at"),
"dispatcher_pid": payload.get("dispatcher_pid"),
}
if field == "ci":
# ─── Contract divergence (intentional) ────────────────
# Every OTHER field gated on a completion flag uses
# _gated_list / _gated_text and returns ``_MISSING``
# when the flag is False (empty stdout → worker falls
# through to legacy GET). ``ci`` is the exception: it
# ALWAYS emits the bundle when the sentinel exists at
# all, and the worker is expected to inspect the inner
# ``status_completed`` / ``detail_completed`` booleans
# to decide what to do.
#
# Why diverge:
# 1. CI carries TWO independent completion signals
# (status + detail). Collapsing them into one
# ``_MISSING`` would lose the case where the status
# summary fetch succeeded but the per-check detail
# fetch failed — the worker still has useful
# aggregate state ("PR is red"), it just lacks
# per-check evidence.
# 2. A green PR legitimately has ``ci_detail=[]`` (no
# failing checks to enumerate) with
# ``ci_detail_completed=True``. Emitting that as a
# bundle is the right answer.
#
# Workers reading ``--field ci`` MUST check both
# completion booleans before treating the bundle as
# authoritative. See SKILL.md "ci field exception" for
# the worker-side procedure.
return {
"status": payload.get("ci_status"),
"checks": payload.get("ci_detail") or [],
"status_completed": payload.get("ci_status_completed", True),
"detail_completed": payload.get("ci_detail_completed", True),
}
if field == "comments":
# Dispatch on ``work_type``: pr_fix / request_changes_pr
# cycles populate ``pr_comments``; new_issue cycles populate
# ``issue_comments``. Mixing them was a latent bug — if a
# future change ever populated both (e.g. an issue_impl
# cycle that also happened to have a stale PR), the previous
# ``or`` chain would return the wrong one.
if payload.get("work_type") == "issue_impl":
return _gated_list(
payload, "issue_comments", "issue_comments_completed"
)
return _gated_list(payload, "pr_comments", "pr_comments_completed")
if field == "comments_digest":
# The deterministic attempt-history digest the dispatcher
# computed from the FULL comment list at prefetch time (see
# _attempt_history.summarize_attempt_history). The verbatim
# ``comments`` field only carries the bounded view; this is the
# rollup of everything dropped from that window. Always emit the
# dict when the sentinel exists — an empty ``{}`` means
# "computed, zero attempts parsed", still authoritative. There
# is no legacy GET to fall through to.
return payload.get("pr_comments_digest") or {}
if field == "reviews":
return _gated_list(
payload,
"request_changes_reviews",
"request_changes_reviews_completed",
)
if field == "comment_reviews":
# R3.4 (2026-05-17): non-RC reviews (COMMENT/APPROVE) — the
# reviewer's advisory feedback. Separate field from RC so
# callers can keep the blocking-vs-advisory distinction.
return _gated_list(
payload,
"comment_reviews",
"comment_reviews_completed",
)
if field == "issues":
return _gated_list(payload, "linked_issues", "linked_issues_completed")
if field == "epic":
# ``epic_completed=False`` means the fetch failed; fall
# through. ``epic_completed=True`` with ``epic=None`` means
# the dispatcher confirmed the PR has no Epic reference —
# emit ``null`` so the worker skips its legacy GET.
if not payload.get("epic_completed", True):
return _MISSING
return payload.get("epic")
if field in (
"compliance_gaps", "gate_preflight", "recent_implementer_push",
):
# Deterministic / cross-cycle sections — BINARY contract
# (not the generic three-case prefetch contract). The
# dispatcher either computed this cycle (``*_completed=True``
# AND a populated dict, emitted as JSON) or it did not
# (both keys absent, emitted as empty stdout / worker falls
# through). The detectors always produce a structured dict
# when they run, so there is no "tried-but-empty" middle
# state to surface.
completed_key = f"{field}_completed"
if not payload.get(completed_key):
return _MISSING
return payload.get(field)
raise ValueError(f"unknown --field: {field}")
_ALLOWED_FIELDS = (
"all",
"description",
"title",
"diff",
"metadata",
"ci",
"comments",
"comments_digest",
"reviews",
"issues",
"epic",
"issue_body",
"compliance_gaps",
"gate_preflight",
"recent_implementer_push",
)
def _emit(value: Any, field: str) -> None:
"""Render ``value`` to stdout in the format the worker expects.
Three cases (see :data:`_MISSING` docstring for full rationale):
- ``value is _MISSING`` → emit nothing. The worker reads empty
stdout and falls through to its legacy GET.
- ``value is None`` → emit ``null\\n``. The worker reads
non-empty stdout and skips its legacy GET (the dispatcher
authoritatively confirmed absence).
- any other value → emit verbatim. Strings print as plain text
with a trailing newline; everything else prints as
pretty-printed JSON.
"""
if value is _MISSING:
return
if value is None:
# Authoritative "no data" answer (e.g. dispatcher confirmed
# no Epic). The worker contract reads this as "skip the
# legacy GET; there's nothing to fetch." JSON ``null`` is
# parseable in any language the worker might use.
sys.stdout.write("null\n")
return
if isinstance(value, str):
# Plain-text contract for these fields so the worker can
# pipe them straight into a file or display them inline
# without an extra json parse.
sys.stdout.write(value)
# Ensure a trailing newline so terminal usage looks right.
if not value.endswith("\n"):
sys.stdout.write("\n")
return
# Structured fields: pretty-print JSON so the worker (or an
# operator inspecting from the shell) gets a readable blob.
json.dump(value, sys.stdout, indent=2, default=str)
sys.stdout.write("\n")
def _cmd_read(args: argparse.Namespace) -> int:
"""Implement ``read``. Always exits 0 on the "no data" path —
that's a normal condition (e.g. epic-field on a PR with no
Epic reference). Exit 3 only on schema mismatch, which is a
config issue the operator must notice."""
payload, error = _load(int(args.pr))
if error:
print(f"warning: {error}", file=sys.stderr)
if "schema_version" in error:
return 3
return 0
if payload is None:
# No sentinel — the dispatcher didn't prefetch (or cleaned
# up already). Empty stdout is the "no data" signal.
return 0
try:
value = _project_field(payload, args.field)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
_emit(value, args.field)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="implementer_pr_context",
description=(
"Worker-side reader for the dispatcher's PR-context "
"handoff sentinel. See module docstring for the full "
"contract."
),
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_read = sub.add_parser(
"read",
help=(
"Read one field from the PR's context handoff. Prints "
"the field on stdout (plain text for description/title/"
"diff/issue_body, JSON for everything else). Empty "
"stdout means 'no data available — fall back to curl'."
),
)
p_read.add_argument(
"--pr", required=True, type=int, help="PR number"
)
p_read.add_argument(
"--field",
required=True,
choices=_ALLOWED_FIELDS,
help="Which slice of the prefetched data to print",
)
args = parser.parse_args(argv)
if args.cmd == "read":
return _cmd_read(args)
parser.error(f"unknown subcommand {args.cmd!r}")
return 2 # pragma: no cover — argparse exits before reaching here
if __name__ == "__main__":
sys.exit(main())