Files
cleveragents-core/tools/controller/worker/roles.py
T
drew 016b348117 feat(controller): grooming gate (Phase 0 + Phase 1 worker-shape dispatch)
Phase 0 (foundation):
- Cause enum (controller_events.cause) for action attribution
- Schema: grooming_decisions audit table; workflows gains
  grooming_evaluated_at + deferred_reason + deferred_at +
  deferred_target_workflow_id; pulls gains touched_files
- audit_comments: CLOSE / DEFER templates + render_comment_template
- forgejo_writes: close_issue + defer_issue 5-step crash-safe protocol
  (fingerprint dedup, error matrix, dry-run)
- patch_pr_state callback in forgejo_http
- grooming_config: 22-env-var frozen-dataclass config + log_effective
- pulls.touched_files cache extension (_pipeline_cache.py schema v8)
- reaper.reap_grooming_decisions audit-retention sweep
- reconciliation RESUME guard (deferred_reason)

Phase 1 (worker-queue shape, 2026-05-25):
- New state: GROOMING. New events: grooming_started, groom_verdict_
  {proceed,defer,close}. 5 new transitions; all invariants still clean
- GroomingInputV1 + GroomingOutputV1 Pydantic contracts
- outcomes._map_grooming_outcome routes verdicts to state-machine events
- prefetch.build_grooming_stage_b_input + list_open_prs callback
- scheduler GROOMING -> grooming_stage_b role
- promote: cfg-gated DISCOVERED -> GROOMING when CONTROLLER_GROOMING_
  ENABLED=true; issues skip grooming
- forgejo_writes decomposed: close_act/defer_act (Forgejo writes only;
  state-machine already transitioned) + close_decide_and_act/
  defer_decide_and_act (Phase 0 callers); _apply_workflow_transition
  is underscore-private
- grooming.py library: tokenization, suspicion scoring (Jaccard +
  weighted overlap), deterministic checks, action -> verdict mapping
- mcp/grooming_builder.py: 14-tool FastMCP server emits GroomingOutputV1
- .opencode/agents/grooming-stage-b.md: duplicate-detection agent
  prompt (claude-haiku-4-5)
- grooming_side_effects.run_grooming_side_effects_tick: per-state tick
  performs Forgejo writes after groom_verdict_{defer,close} fires.
  Filters on event_type='transition' + payload.event (centralizes the
  convention pending Phase 2's latest_transition_event helper)
- GroomingCallbacks frozen dataclass; loop.py + __main__.py wired

Worker role registry (single source of truth):
- worker/roles.py: WORKER_ROLES + WorkerRoleSpec + default_roles_csv
  + output_filename_for. agent_runner.ROLE_TO_MCP_MODULE / ROLE_TO_
  OUTPUT_MODEL derive from it; opencode_session.agent_name_for reads
  it for flat cases; all 6 prompt builders use output_filename_for;
  worker --roles default = default_roles_csv(); launcher script
  derives --roles via shell substitution. Cross-site invariant test
  enforces alignment across 5 sites + opencode.json MCP registry.

Phase 0 silent-bug fix:
- reconciliation.py RESUME guard SELECT now includes deferred_reason
  (was missing since Phase 0; guard was a silent no-op). Tightened
  from getattr to attribute access to fail fast on future omissions.

Tests (1456 total, +91 grooming-specific):
- test_grooming_phase0.py: 34 tests (orchestrator matrix, crash
  recovery, idempotency, dry-run)
- test_grooming_phase1.py: 60 tests (library, contracts, state
  machine, outcomes, scheduler, promote, prefetch, act-variants
  with signature parity, side-effect tick incl. natural-idempotency
  + executed-flag-skip + verdict-mismatch + reconciliation RESUME)
- test_mcp_builders.py TestGroomingBuilder: 29 tests (happy paths
  + 22 validation rules + Pydantic round-trip + master-tick-read-
  path companion)
- test_worker_agent_runner.py TestRoleMaps: cross-role wiring
  alignment + agent-prompt-vs-worker-fallback filename contract +
  inspect.signature equality (close_act/defer_act vs
  close_issue/defer_issue)
- test_state_machine.py: transition count 51 -> 56 +
  events_from_grooming

Live-validated end-to-end on 4 staged sentinel PRs (#55-#58) in
dry_run: agent emits verdicts via MCP, state-machine transitions
fire, side-effect tick writes audit row, deferred_reason gates
reconciliation RESUME correctly.

Deferred refinements + Phase 2 prerequisite (latest_transition_event
helper) tracked in .drew/regressions-plan.md "Phase 1 follow-up
backlog".

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

190 lines
7.1 KiB
Python

"""Worker-role registry — single source of truth for every site that
participates in dispatching a controller role to a worker.
## Why this exists
The controller pipeline wires each worker role through five-plus
disconnected sites:
1. ``ROLE_TO_MCP_MODULE`` (``agent_runner.py``) — which MCP subprocess
the worker spawns per attempt.
2. ``ROLE_TO_OUTPUT_MODEL`` (``agent_runner.py``) — which Pydantic
model the canonical output is parsed against.
3. ``agent_name_for`` (``opencode_session.py``) — which OpenCode agent
drives the LLM session.
4. ``build_prompt`` (``prompts.py``) — the per-role prompt builder.
5. ``--roles`` default in ``worker/__main__.py`` — which roles the
worker actually dequeues.
6. The launcher script ``run-controller-state-machine-pipeline.sh``
``--roles`` flag — which roles the worker process is started with.
7. ``.opencode/opencode.json`` MCP servers — OpenCode serves ONLY the
MCPs registered there; missing entry → agent sees "tool unavailable"
at runtime.
Phase 1 validation discovered the grooming role missing from sites
1, 2, 3, 5, 6, AND 7 simultaneously — the role-map sanity test only
covered (1)+(2), so the wiring shipped broken AND the tests stayed
green. The fix is *not* "add more sanity tests for each site"; it's
"have ONE source of truth, then enforce that every consumer reads
from it."
This module is that source of truth.
## Adding a new role
1. Add ONE entry to ``WORKER_ROLES`` below.
2. Add the matching ``"<agent_name>-response-builder"`` block to
``.opencode/opencode.json`` MCP servers list.
3. Run the controller pytest suite. ``TestRoleMaps`` in
``tests/auto_agents/controller/test_worker_agent_runner.py``
fails loudly if any cross-cutting site is out of alignment.
That's it. ``agent_runner.ROLE_TO_MCP_MODULE``,
``agent_runner.ROLE_TO_OUTPUT_MODEL``,
``opencode_session.agent_name_for`` (for flat-mapped agents), the
worker ``--roles`` default, the launcher script's ``--roles`` flag,
and the canonical-output filename are all derived from the registry
or enforced against it.
"""
from __future__ import annotations
from dataclasses import dataclass
from ..contracts.v1 import (
ConflictResolverOutputV1,
EstimatorOutputV1,
GroomingOutputV1,
ImplementerOutputV1,
ReviewerOutputV1,
SummarizerOutputV1,
)
@dataclass(frozen=True)
class WorkerRoleSpec:
"""Cross-cutting wiring for one worker role.
Frozen so a role's spec can't be mutated mid-run (the registry
is a constant table read at import time)."""
# Stable role name. Matches ``workflow_attempts.role`` AND the
# ``WHERE current_state IN (...)`` clause in
# ``scheduler._role_for_state``.
name: str
# Python module path for the per-attempt MCP subprocess. The
# worker spawns this via ``python -m <mcp_module>``. MUST also be
# registered in ``.opencode/opencode.json`` MCP servers — OpenCode
# only serves what's listed there.
mcp_module: str
# Pydantic model the canonical output is parsed against. The
# worker uses this in ``agent_runner.finalize_attempt`` to
# strict-parse the worker's emission.
output_model: type
# Default OpenCode agent name (matches
# ``.opencode/agents/<agent_name>.md`` and the agent_name registered
# in ``opencode.json``'s ``agents`` map). Roles with
# tier-dependent or input-dependent agents may override this in
# ``opencode_session.agent_name_for``; the registered value here is
# the static fallback / "flat" case.
agent_name: str
# True iff this role's prompt builder requires a tier argument.
requires_tier: bool = False
# ─── the registry ────────────────────────────────────────────────────
# THE ONLY PLACE TO ADD A ROLE. Read by:
# - ``agent_runner.ROLE_TO_MCP_MODULE`` (computed view, public API kept
# for the many existing callers)
# - ``agent_runner.ROLE_TO_OUTPUT_MODEL`` (computed view)
# - ``opencode_session.agent_name_for`` (the simple-mapping fallback)
# - ``worker/__main__.py`` ``--roles`` default
# - ``output_filename_for`` (the canonical-output filename convention)
# - ``test_worker_agent_runner.py::TestRoleMaps`` (cross-site invariant
# tests)
WORKER_ROLES: dict[str, WorkerRoleSpec] = {
"implementer": WorkerRoleSpec(
name="implementer",
mcp_module="tools.controller.mcp.implementer_builder",
output_model=ImplementerOutputV1,
# Tier-suffixed in ``agent_name_for``: implementer +
# tier=N → ``task-implementor-tier-N``.
agent_name="task-implementor",
requires_tier=True,
),
"reviewer": WorkerRoleSpec(
name="reviewer",
mcp_module="tools.controller.mcp.reviewer_builder",
output_model=ReviewerOutputV1,
# Dispute variant chosen in ``agent_name_for`` based on
# ``input_payload['implementer_claim']``.
agent_name="pr-review-worker",
),
"estimator": WorkerRoleSpec(
name="estimator",
mcp_module="tools.controller.mcp.estimator_builder",
output_model=EstimatorOutputV1,
agent_name="estimator-implementation",
),
"conflict_resolver": WorkerRoleSpec(
name="conflict_resolver",
mcp_module="tools.controller.mcp.conflict_resolver_builder",
output_model=ConflictResolverOutputV1,
agent_name="conflict-resolver-worker",
requires_tier=True,
),
"summarizer": WorkerRoleSpec(
name="summarizer",
mcp_module="tools.controller.mcp.summarizer_builder",
output_model=SummarizerOutputV1,
agent_name="controller-summarizer",
),
"grooming_stage_b": WorkerRoleSpec(
name="grooming_stage_b",
mcp_module="tools.controller.mcp.grooming_builder",
output_model=GroomingOutputV1,
agent_name="grooming-stage-b",
),
}
# ─── derived views ───────────────────────────────────────────────────
def output_filename_for(role: str) -> str:
"""The filename the worker watches as its canonical-output
fallback for ``role``. Convention: ``{role}_output.json``.
The worker's polling loop in ``agent_runner.py:226`` constructs
the fallback path as ``{workspace_dir}/{role}_output.json``; the
per-role prompt builders MUST tell the agent to write to the same
filename. The
``test_agent_prompt_output_path_matches_worker_fallback`` test
pins this contract.
"""
if role not in WORKER_ROLES:
raise ValueError(
f"unknown role {role!r}; expected one of {sorted(WORKER_ROLES)}"
)
return f"{role}_output.json"
def default_roles_csv() -> str:
"""Default value for the worker's ``--roles`` CLI flag.
Returns a comma-separated, deterministically-ordered list of every
registered role. The worker accepts a subset via ``--roles`` to
deploy specialised pools.
"""
return ",".join(sorted(WORKER_ROLES))
__all__ = [
"WORKER_ROLES",
"WorkerRoleSpec",
"default_roles_csv",
"output_filename_for",
]