Files
cleveragents-core/tools/dispatch_implementer.py
T
drew 593d142f6f feat(auto-agents): Tier 2 deterministic review/implementer dispatchers
Replaces the long-running pr-review-supervisor / implementation-
supervisor LLM polling loops with host-level Python dispatchers that
own queueing, claim ownership, watchdogs, and SQLite telemetry. The
LLM workers retain sole responsibility for review judgment and code
generation; Python owns only orchestration. The hard merge invariant
is unaffected — these dispatchers do not touch master.

Driver surface
- _opencode_worker.run_session_blocking — outcome-agnostic OpenCode
  session lifecycle (completed / timeout / transport-error). Used
  directly by reviewer / implementer dispatchers whose workers do
  not emit the conflict-driver JSON exit schema.
  run_worker_blocking is now a thin wrapper that adds the
  conflict-specific JSON-outcome classification.
- _dispatch_runtime — shared Python runtime (work-group polling,
  claim helpers, dispatch loop, telemetry). Pre-checks issue labels
  before claiming and refuses when any auto/claimed-* is already
  present, distinguishing already-claimed vs labels-fetch-failed
  vs claim-failed terminal states. Frozen DispatchConfig.
- dispatch_review.py / dispatch_implementer.py — per-pipeline work
  groups, prompts, and CLIs. Each refuses startup when a competing
  AUTO-REV-SUP / AUTO-IMP-SUP legacy supervisor is live on the
  same OpenCode server (override:
  {REVIEW,IMPLEMENTER}_DISPATCHER_ALLOW_SUPERVISOR_COEXIST=1).
- _loader.py — shared sibling-module loader; replaces the three
  duplicated copies in the dispatcher entry points.

Operational hardening
- run_outer_loop tracks consecutive cycle exceptions against
  cycle_failure_budget (default 5, env-tunable per driver) and
  exits 2 on exhaustion for supervisor-driven restart.
- _sanitize_release_detail strips control bytes and neutralises
  triple-backtick fences before quoting worker raw_response in
  Forgejo claim-release comments.
- scripts/opencode-builder.sh: OPENCODE_BUILDER_SERVER_ONLY=1 keeps
  only the OpenCode HTTP API up so the Python dispatchers own
  queue orchestration without auto-agents running concurrently.

Telemetry
- _forgejo_cache.py schema v4: dispatch_review_cycles,
  dispatch_implementer_cycles. One row per cycle with cycle_id,
  driver, candidates_count, claims_acquired, swept_count,
  processed_count, terminal_state, worker_outcome, session_id,
  worker_wallclock_seconds, raw.
- .opencode/telemetry/server.py wires the new tables into
  /api/cycles?driver=dispatch_review|dispatch_implementer and
  surfaces a composite terminal_state/worker_outcome 24h breakdown
  so dashboards can distinguish session-level vs work-level
  outcomes.

Tests
- 31 new tests in tests/auto_agents/test_dispatch_runtime.py
  covering: candidate priority/dedup, claim/release labels,
  foreign-claim refusal, same-kind-claim refusal,
  labels-fetch-failed terminal state, sanitization, supervisor
  coexistence guard (pass/refuse/override/unreachable-server),
  session timeout / transport-error propagation,
  JSON-vs-no-JSON worker exits, cycle failure budget exit and
  reset, heartbeat cadence, end-to-end --once --dry-run /
  --status CLI smoke, and full prompt-snapshot tests for
  _review_prompt and _implementation_prompt (PR-fix + issue-impl).
- test_telemetry_schema.py asserts schema v4 and the presence of
  the two new dispatcher cycle tables.

338 auto_agents tests pass (was 322 before Tier 2). Conflict driver
regression suite unchanged. Dispatchers run cleanly under
--status / --once --dry-run with no Forgejo or OpenCode HTTP traffic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:12:09 -04:00

195 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Deterministic dispatcher for implementation workers.
The dispatcher preserves the existing priority order from
``implementation-supervisor``: 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.
"""
from __future__ import annotations
import argparse
import json
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 load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
_dispatch = _load_sibling("_dispatch_runtime", "_dispatch_runtime.py")
DRIVER_NAME = "dispatch_implementer.py"
CLAIM_KIND = "implementer"
def _implementation_prompt(cfg: Any, item: dict[str, Any], group: Any) -> str:
work_type = "issue_impl" if group.item_kind == "issue" else "pr_fix"
title = str(item.get("title") or "")
number = int(item["number"])
claim_note = (
"The deterministic dispatcher already claimed "
"`auto/claimed-implementer` before starting this worker. If your "
"startup claim step sees the label already present, treat that as "
"success and continue normally. Still run your release step before "
"exiting; the dispatcher will also release in its finally block."
if work_type == "pr_fix"
else "No PR exists yet for this issue work, so there is no "
"`auto/claimed-implementer` claim to acquire before dispatch."
)
return f"""Implement or fix the indicated issue or pull request.
forgejo_url: `{cfg.forgejo_url}`
forgejo_owner: `{cfg.owner}`
forgejo_repo: `{cfg.repo}`
work_type: {json.dumps(work_type)}
work_number: {number}
work_title: {json.dumps(title)}
PR Compliance Checklist (MANDATORY - complete ALL items before creating a PR):
[ ] 1. CHANGELOG.md — add entry under [Unreleased] section
[ ] 2. CONTRIBUTORS.md — add or update contribution entry
[ ] 3. Commit footer — include `ISSUES CLOSED: #<issue-number>` in the commit message
[ ] 4. CI passes — all quality gates and tests green before requesting review
[ ] 5. BDD/Behave tests — added or updated for the changed behaviour
[ ] 6. Epic reference — PR description references the parent Epic issue number
[ ] 7. Labels — applied via forgejo-label-manager: State/In Review, Priority/<level>, MoSCoW/<level>, Type/<type>
[ ] 8. Milestone — PR assigned to the earliest open milestone matching the issue
{claim_note}
When the implementation work is complete, include exactly one JSON object in
your final response:
{{"outcome": "resolved", "files_touched": ["path/changed"]}}
If you cannot complete the implementation because of an unrecoverable setup,
API, or repository problem, include:
{{"outcome": "rebase-failed", "files_touched": []}}
"""
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.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.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,
),
]
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")
),
)
SUPERVISOR_TAGS = ["AUTO-IMP-SUP"]
SUPERVISOR_OVERRIDE_ENV = "IMPLEMENTER_DISPATCHER_ALLOW_SUPERVISOR_COEXIST"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="run one cycle and exit")
parser.add_argument("--status", action="store_true", help="print config and exit")
parser.add_argument("--dry-run", action="store_true", help="claim nothing and do not dispatch")
args = parser.parse_args()
_dispatch._configure_logging("IMPLEMENTER_DISPATCHER_LOG_LEVEL")
cfg = load_config(dry_run=args.dry_run)
if args.status:
_dispatch.json_line(_dispatch.status_payload(cfg, driver_name=DRIVER_NAME))
return 0
if not args.dry_run:
_dispatch.assert_no_legacy_supervisor(
cfg,
driver_name=DRIVER_NAME,
supervisor_tags=SUPERVISOR_TAGS,
override_env=SUPERVISOR_OVERRIDE_ENV,
)
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())