016b348117
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>
190 lines
7.1 KiB
Python
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",
|
|
]
|