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

167 lines
6.4 KiB
Python

"""Audit-comment templates and the post-commit substitution helper.
Phase 0 deliverable (grooming plan): the ``close_issue`` and
``defer_issue`` callbacks post a Markdown audit comment to Forgejo
that ties the action back to a row in the ``grooming_decisions``
audit table by its ``decision_id``.
The ``decision_id`` is only known **after** the in-txn audit-row
insert commits, so the templates carry a literal sentinel
``<substituted-from-step-1>`` that ``render_comment_template``
replaces with the actual id. All OTHER placeholders in the
templates use ``str.format``-style ``{name}`` markers and are
filled by the caller BEFORE the template reaches the render helper.
The two kinds of placeholders coexist because they live in
different lifecycle stages:
- ``{name}`` markers: caller-supplied values (gate, reason
category, canonical PR number, LLM confidence/reasoning, etc.)
— known at the moment the caller assembles the comment.
- ``<substituted-from-step-1>``: the audit row's primary key —
known only after the DB transaction commits.
See ``.drew/regressions-plan.md`` Phase 0, "Audit ID
substitution" and decision #25 for the design rationale.
"""
from __future__ import annotations
import re
_PLACEHOLDER_RE = re.compile(r"<substituted-from-step-\d+>")
_KNOWN_PLACEHOLDERS: frozenset[str] = frozenset({"<substituted-from-step-1>"})
def render_comment_template(template: str, *, decision_id: int) -> str:
"""Substitute the post-commit ``decision_id`` into a comment template.
Replaces every occurrence of ``<substituted-from-step-1>`` with the
string form of ``decision_id``. Raises ``ValueError`` if the
template contains any ``<substituted-from-step-N>`` marker for
an unknown ``N`` (whitelist check — guards against silently
leaving an unsubstituted placeholder in the posted comment).
Does NOT scan for generic ``<...>`` patterns in the template,
because the comment body legitimately contains user-controlled
angle brackets (e.g. an LLM's preserved-value summary referencing
a Java type like ``Map<String, Object>``).
The helper is also defensive about the substitution itself:
after the ``replace`` it re-checks that no
``<substituted-from-step-1>`` text remains in the output. The
only way that could fail is a future caller-bug where the
sentinel appears inside data that was substituted in by an
earlier ``str.format`` call, which is worth catching loudly.
"""
found = set(_PLACEHOLDER_RE.findall(template))
unknown = found - _KNOWN_PLACEHOLDERS
if unknown:
raise ValueError(
"render_comment_template: template contains unknown placeholders "
f"{sorted(unknown)}; only {sorted(_KNOWN_PLACEHOLDERS)} are "
"supported. Either add the placeholder to "
"_KNOWN_PLACEHOLDERS + a substitution branch, or remove it "
"from the template."
)
rendered = template.replace("<substituted-from-step-1>", str(decision_id))
if "<substituted-from-step-1>" in rendered:
raise RuntimeError(
"render_comment_template: substitution failed — "
"'<substituted-from-step-1>' still present after replace. "
"This indicates an upstream bug (the sentinel appeared in a "
"caller-supplied value)."
)
return rendered
# ─── close-comment template ─────────────────────────────────────────────
#
# Caller-supplied {name} placeholders (filled via str.format):
# gate — "Gate 1" / "Gate 2" / "Gate 3"
# reason_category — one of the abandon-reason categories
# (see .drew/regressions-plan.md "Abandon-reason
# categories — by gate")
# explanation — one-paragraph human-readable rationale
# canonical_pr_line — "Canonical (if duplicate): #42" OR "" (no line)
# confidence_line — "LLM confidence (when applicable): high" OR ""
# reasoning_line — "LLM reasoning (when applicable): ..." OR ""
#
# Render-helper-supplied placeholder (filled by render_comment_template):
# <substituted-from-step-1> — the grooming_decisions.decision_id
#
# The optional ``*_line`` placeholders are pre-formatted by the caller
# so the template stays linear. Empty-string substitution leaves a
# blank line; callers should pass either a fully-formed line or an
# empty string.
CLOSE_COMMENT_TEMPLATE = """\
[CONTROLLER-CLOSE:{gate}:{reason_category}]
{explanation}
Decision:
- Gate: {gate}
- Reason category: {reason_category}
{canonical_pr_line}
{confidence_line}
{reasoning_line}
Audit ID: <substituted-from-step-1>
---
Automated by the CleverAgents controller pipeline.
Identity: HAL9000 (pipeline action)
"""
# ─── defer-comment template ─────────────────────────────────────────────
#
# Caller-supplied {name} placeholders:
# gate, reason_category, canonical_pr_number, confidence, reasoning,
# preserved_value_line — "Preserved value (when applicable): ..." OR ""
# workflow_id — the deferred workflow's id (used in clear-SQL)
#
# Render-helper-supplied: <substituted-from-step-1>
DEFER_COMMENT_TEMPLATE = """\
[CONTROLLER-DEFER:{gate}:{reason_category}]
This PR has been deferred for re-evaluation. The controller has stepped back
from processing it. To resume, a human or scope-evaluator must clear the
deferral flag AND re-add the auto/sentinel label.
Decision:
- Gate: {gate}
- Reason category: {reason_category}
- Canonical: #{canonical_pr_number}
- LLM confidence: {confidence}
- LLM reasoning: {reasoning}
{preserved_value_line}
To clear the deferral (SQL):
UPDATE workflows SET deferred_reason=NULL,
deferred_at=NULL,
deferred_target_workflow_id=NULL
WHERE workflow_id = {workflow_id};
INSERT INTO controller_events
(workflow_id, ts, event_type, payload, cause, forgejo_write_pending, replay_attempts)
VALUES ({workflow_id}, datetime('now'), 'deferral_cleared',
json_object('cleared_by', 'operator', 'reason', '<your reason>'),
'operator', 0, 0);
Audit ID: <substituted-from-step-1>
---
Automated by the CleverAgents controller pipeline.
Identity: HAL9000 (pipeline action)
"""
__all__ = [
"CLOSE_COMMENT_TEMPLATE",
"DEFER_COMMENT_TEMPLATE",
"render_comment_template",
]