Files
cleveragents-core/tools/dispatch_implementer.py
T
drew d386ff4e8b feat(auto-agents): filesystem handoff for implementer pre-clone + pre-fetch
The 2026-05-10 default-ON flip of IMPLEMENTER_DISPATCHER_PREFETCH /
IMPLEMENTER_DISPATCHER_PRECLONE put rich PR context and a pre-cloned
worktree in the wrapper's prompt — but the deep `task` tool chain
(implementation-worker → tier-dispatcher → tier-qwen-med →
task-implementor) re-summarises the prompt at every level, so by the
time task-implementor sees its input only BEGIN_PR_DIFF survives. The
worker still called git-isolator-util (~82 s wasted) and re-issued
Forgejo GETs for data the dispatcher had already fetched.

This change introduces a filesystem-mediated handshake that is immune
to prompt summarisation. The dispatcher writes two sentinel JSON
files per cycle (workspace handoff next to the worktree; PR-context
handoff in /tmp/cleveragents-implementer-handoff/) and the worker
reads them via two new bash-allowed Python scripts. Missing /
malformed / stale sentinels map to empty stdout, which is the
worker's signal to fall through to the legacy GET / git-isolator-util.

New modules:
- tools/_pr_context_sentinel.py: dispatcher-side writer (atomic, with
  200 KB per-field truncation and idempotent delete).
- tools/implementer_workspace.py: worker-side reader CLI with
  `discover` and safety-checked `cleanup` subcommands.
- tools/implementer_pr_context.py: worker-side reader CLI with `read
  --field <name>` for every prefetch field.

Hooked into:
- tools/_pr_clone.py: prepare_pr_worktree writes the workspace
  sentinel after worktree-add succeeds; WorktreeHandle.cleanup
  removes both worktree and sentinel; new _resolve_branch_for_sha
  populates the sentinel's branch field.
- tools/dispatch_implementer.py: _prefetch_prompt writes the
  PR-context sentinel; _cleanup_clone_handle removes it.

Two new skills (.opencode/skills/implementer-workspace,
.opencode/skills/implementer-pr-context) and a rewrite of every
"Pre-fetched section" wording in .opencode/agents/task-implementor.md
to call the skills first and fall back to legacy GET only on empty
stdout.

Tests: 54 new (16 workspace CLI + 21 PR-context CLI + 7 sentinel
writer + 10 _pr_clone integration). Full auto_agents suite: 1,123
passed, 3 skipped (was 1,069 before).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 22:32:28 -04:00

819 lines
31 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"
)
_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"
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_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 _build_clone_section(cfg: Any, pr_number: int, head_sha: str) -> tuple[str, Any]:
"""Run the Phase 3 pre-clone (when ``IMPLEMENTER_DISPATCHER_PRECLONE=1``)
and return ``(section_text, clone_handle)``.
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, 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)
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
)
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
)
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).
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,
)
return text
def _implementation_prompt_dispatch(
cfg: Any, item: dict[str, Any], group: Any
) -> str:
"""Top-level prompt factory honouring the
``IMPLEMENTER_DISPATCHER_PREFETCH`` flag. ``=1`` produces the
Phase 2 prompt; anything else produces the legacy prompt."""
if _is_prefetch_enabled():
return _prefetch_prompt(cfg, item, group)
return _legacy_implementation_prompt(cfg, item, group)
# ─── 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,
) -> 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,
)
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,
) -> 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,
)
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,
) -> 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
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,
)
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
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=_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=_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=_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())