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>
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""DISCOVERED → ANALYZING (or GROOMING) promotion handler.
|
|
|
|
Discovery creates ``Workflow(current_state='DISCOVERED')`` rows. The
|
|
state machine has the transition ``(DISCOVERED, discovery_picked_up)
|
|
→ ANALYZING``, but no production code fires the event — workflows
|
|
would sit in DISCOVERED forever, never being scheduled for an
|
|
estimator attempt.
|
|
|
|
This module ships the missing promoter: a per-tick scan that finds
|
|
all DISCOVERED workflows and transitions them to the next state via
|
|
``apply_event``. Once promoted, the scheduler picks them up and
|
|
enqueues the appropriate role's attempt.
|
|
|
|
Phase 1 corrected dispatch (2026-05-25): when
|
|
``CONTROLLER_GROOMING_ENABLED=true`` (per ``grooming_config``), the
|
|
promoter fires ``grooming_started`` instead and routes DISCOVERED →
|
|
GROOMING. ANALYZING (and the estimator) follows once the worker emits
|
|
``groom_verdict_proceed``. When grooming is disabled (the default),
|
|
behavior is unchanged: ``discovery_picked_up`` → ANALYZING directly.
|
|
|
|
Composes with the master loop's other ticks (tick / reaper /
|
|
reconciliation / pickup_guard / ci_poll_exhaustion / scheduler).
|
|
Runs every iteration (cheap — typically processes 0-1 row per tick
|
|
since most workflows are quickly past DISCOVERED).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.engine import Engine
|
|
|
|
from ..db.session import session_scope
|
|
from ..state_machine import IllegalTransitionError, apply_event
|
|
from .grooming_config import GroomingConfig, get_grooming_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class PromoteDiscoveredReport:
|
|
"""Per-sweep summary."""
|
|
|
|
workflows_promoted: int = 0
|
|
promoted_workflow_ids: list[int] = field(default_factory=list)
|
|
|
|
|
|
def run_promote_discovered_tick(
|
|
engine: Engine,
|
|
cfg: GroomingConfig | None = None,
|
|
) -> PromoteDiscoveredReport:
|
|
"""One sweep: promote every DISCOVERED workflow.
|
|
|
|
Promotes to GROOMING (firing ``grooming_started``) when
|
|
``cfg.enabled=true``, else to ANALYZING (firing
|
|
``discovery_picked_up``) — the pre-Phase-1 behavior. The decision
|
|
is per-sweep, not per-row, so a single tick can't straddle a
|
|
config flip.
|
|
|
|
Writes a ``discovery-promoted`` controller_events row per
|
|
transition (event_type is shared across both paths; the actual
|
|
state-machine event chosen is recorded in payload['event']).
|
|
"""
|
|
if cfg is None:
|
|
cfg = get_grooming_config()
|
|
next_event = "grooming_started" if cfg.enabled else "discovery_picked_up"
|
|
|
|
report = PromoteDiscoveredReport()
|
|
now = datetime.now(timezone.utc)
|
|
with session_scope(engine) as session:
|
|
rows = session.execute(
|
|
text(
|
|
"SELECT workflow_id, current_state, kind, "
|
|
" owner, repo, entity_number "
|
|
" FROM workflows "
|
|
" WHERE current_state = 'DISCOVERED' "
|
|
# Phase 1 corrected dispatch (worker-shape): grooming
|
|
# uses kind='pr' only. Issue workflows skip grooming
|
|
# entirely and route straight to ANALYZING via
|
|
# discovery_picked_up regardless of cfg.enabled.
|
|
)
|
|
).all()
|
|
for row in rows:
|
|
event_for_row = next_event
|
|
if cfg.enabled and row.kind != "pr":
|
|
event_for_row = "discovery_picked_up"
|
|
try:
|
|
new_state = apply_event(
|
|
row.current_state,
|
|
event_for_row,
|
|
)
|
|
except (IllegalTransitionError, ValueError) as exc:
|
|
logger.warning(
|
|
"promote_discovered: workflow_id=%s state=%r "
|
|
"rejected the event (%s); skipping",
|
|
row.workflow_id,
|
|
row.current_state,
|
|
exc,
|
|
)
|
|
continue
|
|
# TOCTOU defense (R-round4 P3): the SELECT happened earlier;
|
|
# between then and now another master tick / reconciliation
|
|
# could have moved the workflow off DISCOVERED. The UPDATE
|
|
# filter on current_state='DISCOVERED' ensures we only
|
|
# promote rows that are STILL DISCOVERED at write time. If
|
|
# rowcount=0, we silently skip the event row too — no
|
|
# duplicate audit entry for an already-promoted workflow.
|
|
result = session.execute(
|
|
text(
|
|
"UPDATE workflows SET "
|
|
" current_state = :to_state, "
|
|
" last_transition_at = :now, "
|
|
" entered_state_at = :now "
|
|
"WHERE workflow_id = :wf_id "
|
|
" AND current_state = 'DISCOVERED'"
|
|
),
|
|
{
|
|
"to_state": new_state,
|
|
"now": now,
|
|
"wf_id": row.workflow_id,
|
|
},
|
|
)
|
|
if (result.rowcount or 0) == 0:
|
|
# Lost the race — another tick already advanced this
|
|
# workflow. Skip the event-row write to keep the audit
|
|
# trail clean.
|
|
continue
|
|
session.execute(
|
|
text(
|
|
"INSERT INTO controller_events "
|
|
"(workflow_id, ts, event_type, from_state, to_state, "
|
|
" payload, forgejo_write_pending, replay_attempts) "
|
|
"VALUES (:wf_id, :ts, 'discovery-promoted', "
|
|
" :from_state, :to_state, :payload, 0, 0)"
|
|
),
|
|
{
|
|
"wf_id": row.workflow_id,
|
|
"ts": now,
|
|
"from_state": row.current_state,
|
|
"to_state": new_state,
|
|
"payload": json.dumps(
|
|
{
|
|
"reason": "discovery-promoted",
|
|
"kind": row.kind,
|
|
"owner": row.owner,
|
|
"repo": row.repo,
|
|
"entity_number": row.entity_number,
|
|
"source": "promote_discovered",
|
|
"event": event_for_row,
|
|
"to_state": new_state,
|
|
}
|
|
),
|
|
},
|
|
)
|
|
report.workflows_promoted += 1
|
|
report.promoted_workflow_ids.append(row.workflow_id)
|
|
|
|
if report.workflows_promoted:
|
|
logger.info(
|
|
"promote_discovered: %d workflow(s) advanced DISCOVERED → "
|
|
"%s (grooming_enabled=%s) (ids: %s)",
|
|
report.workflows_promoted,
|
|
"GROOMING" if cfg.enabled else "ANALYZING",
|
|
cfg.enabled,
|
|
report.promoted_workflow_ids,
|
|
)
|
|
return report
|
|
|
|
|
|
__all__ = [
|
|
"PromoteDiscoveredReport",
|
|
"run_promote_discovered_tick",
|
|
]
|