Files
cleveragents-core/tools/controller/master/grooming.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

301 lines
10 KiB
Python

"""Grooming library — helpers consumed by the grooming_stage_b worker
and the ``run_grooming_side_effects_tick`` master-side post-processor.
The Phase 1 corrected dispatch (worker-queue shape, 2026-05-25) puts
the substantive grooming logic — deterministic conclusive checks,
Stage A suspicion-score pre-filter, Stage B LLM judgment + semantic
validation — INSIDE the grooming_stage_b worker. The worker emits a
single ``GroomingOutputV1`` payload that the controller's state machine
consumes via ``_map_grooming_outcome``.
What lives here
---------------
This module is the pure-function library the worker (and the master
side-effect tick) imports for the deterministic primitives that
DON'T need the LLM:
- ``tokenize_title`` / ``tokenize_body`` — stopword-filtered token sets
fed into the Stage A Jaccard similarity scoring.
- ``suspicion_score`` — the weighted-Jaccard pre-filter the worker uses
to pick which candidates to actually evaluate at Stage B.
- ``classify_action_to_verdict`` — the per-LLM-action mapper
(full_duplicate / unnecessary / needs_evaluation) → controller verdict
(close / defer) with the ``CLOSE_ENABLED`` policy applied.
- ``DuplicateAction`` + ``Confidence`` — shared StrEnum types.
These helpers have NO Forgejo dependencies and NO DB dependencies; they
are pure functions over the prefetched input dicts.
What does NOT live here
-----------------------
- LLM call / agent invocation — that's the OpenCode-driven worker
side at ``.opencode/agents/grooming-stage-b.md``.
- Forgejo writes — those happen in
``run_grooming_side_effects_tick`` via the decomposed
``forgejo_writes.close_act`` / ``defer_act``.
- State-machine transitions — those happen in tick.py via
``outcomes._map_grooming_outcome`` + ``state_machine.apply_event``.
"""
from __future__ import annotations
import enum
import re
from typing import Iterable
# ─── shared enums ────────────────────────────────────────────────────
class DuplicateAction(str, enum.Enum):
"""The agent's per-duplicate classification.
Subclasses ``str`` so values serialize directly as text into
``grooming_decisions`` audit rows / output_payload JSON.
"""
FULL_DUPLICATE = "full_duplicate"
UNNECESSARY = "unnecessary"
NEEDS_EVALUATION = "needs_evaluation"
class Confidence(str, enum.Enum):
"""Agent's confidence level on the overall duplicate-detection verdict."""
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class GroomingVerdict(str, enum.Enum):
"""Top-level grooming verdict — what the controller does with the workflow."""
PROCEED = "proceed"
DEFER = "defer"
CLOSE = "close"
# ─── tokenization ────────────────────────────────────────────────────
# Title/body stopwords — keeps Jaccard scoring from over-matching on
# function words. Sourced from the grooming-worker prompt convention on
# the agents/final-working branch + standard English stoplist.
_STOPWORDS: frozenset[str] = frozenset(
{
"a", "an", "and", "are", "as", "at", "be", "but", "by", "for",
"from", "have", "has", "in", "into", "is", "it", "its", "of", "on",
"or", "that", "the", "this", "to", "was", "were", "will", "with",
# PR-jargon stopwords
"pr", "wip", "draft", "fix", "fixes", "update", "updates",
"add", "adds", "use", "make", "makes",
}
)
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]{1,}")
def _tokenize_text(text: str, *, max_tokens: int = 200) -> frozenset[str]:
"""Lowercase + word-split + stopword-strip. Returns a frozenset of
the first ``max_tokens`` distinct tokens after filtering."""
if not text:
return frozenset()
found: list[str] = []
seen: set[str] = set()
for match in _TOKEN_RE.finditer(text.lower()):
tok = match.group(0)
if tok in _STOPWORDS or len(tok) < 3 or tok in seen:
continue
seen.add(tok)
found.append(tok)
if len(found) >= max_tokens:
break
return frozenset(found)
def tokenize_title(title: str | None) -> frozenset[str]:
"""Tokenize a PR title for Stage A title-jaccard scoring."""
return _tokenize_text(title or "", max_tokens=50)
def tokenize_body(body: str | None, *, head_bytes: int = 2048) -> frozenset[str]:
"""Tokenize a PR body. Per the plan: first ~2KB after stripping
triple-backtick code fences (code blocks dominate token overlap with
nothing useful for duplicate detection)."""
if not body:
return frozenset()
stripped = re.sub(r"```[\s\S]*?```", "", body)
return _tokenize_text(stripped[:head_bytes], max_tokens=200)
def jaccard(a: Iterable[str], b: Iterable[str]) -> float:
"""Jaccard similarity over two token sets. Empty-on-empty → 0.0."""
sa = a if isinstance(a, (set, frozenset)) else frozenset(a)
sb = b if isinstance(b, (set, frozenset)) else frozenset(b)
if not sa or not sb:
return 0.0
inter = len(sa & sb)
union = len(sa | sb)
return inter / union if union else 0.0
# ─── Stage A suspicion scoring ───────────────────────────────────────
# Default weights from the plan (sum to 1.0).
DEFAULT_WEIGHTS: dict[str, float] = {
"title": 0.25,
"body": 0.25,
"files": 0.30,
"branch": 0.10,
"closes": 0.10,
}
_CLOSES_RE = re.compile(
r"\b(?:closes|fixes|resolves)\s+#(\d+)\b",
re.IGNORECASE,
)
def extract_closes_refs(body: str | None) -> frozenset[int]:
"""Extract issue numbers referenced via ``Closes #N`` / ``Fixes #N`` /
``Resolves #N`` in the PR body."""
if not body:
return frozenset()
return frozenset(int(m.group(1)) for m in _CLOSES_RE.finditer(body))
def _branch_slug_similarity(a: str | None, b: str | None) -> float:
"""Rough branch-name similarity. 1.0 on exact match, otherwise
Jaccard on hyphen-split slug tokens. (Stand-in for the >0.7 cutoff
in the suspicion formula.)"""
if not a or not b:
return 0.0
if a == b:
return 1.0
aset = frozenset(t for t in re.split(r"[-_/]+", a.lower()) if t)
bset = frozenset(t for t in re.split(r"[-_/]+", b.lower()) if t)
return jaccard(aset, bset)
def suspicion_score(
anchor_pr: dict,
other_pr: dict,
*,
weights: dict[str, float] | None = None,
branch_similarity_threshold: float = 0.7,
) -> float:
"""Compute the Stage A suspicion score between an anchor PR dict
and another open-PR dict.
Per the plan:
suspicion = w_title * jaccard(title_tokens(anchor), title_tokens(other))
+ w_body * jaccard(body_tokens(anchor), body_tokens(other))
+ w_files * jaccard(touched_files(anchor), touched_files(other))
+ w_branch * indicator(head_branch_slug_similarity > 0.7)
+ w_closes * indicator(same_Closes_keyword)
The dicts are Forgejo PR-detail shapes plus an optional
``touched_files`` field the prefetch cache provides. Missing data
contributes 0.0 to its term (so a partial signal still scores
meaningfully).
"""
w = weights or DEFAULT_WEIGHTS
title_a = tokenize_title(anchor_pr.get("title"))
title_b = tokenize_title(other_pr.get("title"))
body_a = tokenize_body(anchor_pr.get("body"))
body_b = tokenize_body(other_pr.get("body"))
files_a = frozenset(anchor_pr.get("touched_files") or [])
files_b = frozenset(other_pr.get("touched_files") or [])
head_a = (
anchor_pr.get("head", {}).get("ref")
if isinstance(anchor_pr.get("head"), dict)
else None
)
head_b = (
other_pr.get("head", {}).get("ref")
if isinstance(other_pr.get("head"), dict)
else None
)
branch_indicator = (
1.0
if _branch_slug_similarity(head_a, head_b) > branch_similarity_threshold
else 0.0
)
closes_a = extract_closes_refs(anchor_pr.get("body"))
closes_b = extract_closes_refs(other_pr.get("body"))
closes_indicator = 1.0 if (closes_a and closes_a & closes_b) else 0.0
return (
w["title"] * jaccard(title_a, title_b)
+ w["body"] * jaccard(body_a, body_b)
+ w["files"] * jaccard(files_a, files_b)
+ w["branch"] * branch_indicator
+ w["closes"] * closes_indicator
)
# ─── deterministic conclusive checks ─────────────────────────────────
# Each check inspects the prefetched data and returns either None
# (check did NOT fire) or a (reason_category, target_workflow_id_or_None)
# tuple that the worker turns into a deterministic verdict. Pure
# functions over dicts; no I/O.
def check_linked_issue_closed(
anchor_pr: dict,
closed_issue_numbers: frozenset[int],
) -> str | None:
"""If the anchor body has ``Closes #N`` AND issue N is already
closed, return the reason_category 'linked_issue_closed'."""
refs = extract_closes_refs(anchor_pr.get("body"))
if refs and refs & closed_issue_numbers:
return "linked_issue_closed"
return None
# ─── verdict / action mapping ────────────────────────────────────────
def classify_action_to_verdict(
action: DuplicateAction | str,
*,
close_enabled: bool,
) -> GroomingVerdict:
"""Map a per-LLM-duplicate action to the controller verdict, applying
the CLOSE_ENABLED policy from the plan:
| LLM action | close_enabled=false | close_enabled=true |
|------------------|---------------------|--------------------|
| full_duplicate | DEFER | CLOSE |
| unnecessary | DEFER | CLOSE |
| needs_evaluation | DEFER | DEFER | (always)
"""
if isinstance(action, str):
action = DuplicateAction(action)
if action == DuplicateAction.NEEDS_EVALUATION:
return GroomingVerdict.DEFER
return GroomingVerdict.CLOSE if close_enabled else GroomingVerdict.DEFER
__all__ = [
"Confidence",
"DEFAULT_WEIGHTS",
"DuplicateAction",
"GroomingVerdict",
"check_linked_issue_closed",
"classify_action_to_verdict",
"extract_closes_refs",
"jaccard",
"suspicion_score",
"tokenize_body",
"tokenize_title",
]