Files
cleveragents-core/tools/controller/master/forgejo_writes.py
T
drew a91df787d7 feat(controller): Phase 2 — Gate 2 estimator-abandon
Adds the second of three abandon gates: the estimator (Gate 2) can
mark a work item fundamentally unworkable, transitioning the
workflow ANALYZING -> ABANDONED and triggering a Forgejo close via
Phase 1's decomposed close_act orchestrator. Catches abandon cases
at the cheapest LLM stage, before implementer/reviewer tiers fire.

Substantive:
- EstimatorOutputV1: additive verdict + abandon_reason_category +
  abandon_reason_detail fields (pre-Phase-2 outputs still parse).
  @model_validator enforces abandon-requires-category atomicity at
  parse time — third defense layer beyond MCP setter + outcomes
  mapper
- state_machine: estimator_abandon event + (ANALYZING,
  estimator_abandon) -> ABANDONED. 57 transitions; invariants clean
- mcp/estimator_builder: estimator_set_verdict setter validates
  verdict enum + 9-category whitelist (scope_intractable,
  intent_wrong, security_regression, deprecated_dependency,
  breaks_protected_invariants, out_of_scope, low_value,
  unmaintained_path, policy_violation) + cross-field rules
- outcomes._map_estimator_outcome: dispatch verdict='abandon'
  -> estimator_abandon, with confidence-low downgrade to
  estimator_done (honors the agent prompt's documented "high or
  medium" requirement)
- estimator_abandon_side_effects.py: per-state side-effect tick
  modeled on grooming_side_effects.py; invokes close_act with
  cause=Cause.ESTIMATOR_ABANDON + event_type='estimator_abandon'
- _events.py: shared latest_transition_event +
  workflows_with_latest_transition_in helpers; dialect-aware
  payload['event'] extraction (SQLite json_extract +
  PostgreSQL ->>); centralizes the event_type='transition' +
  payload['event'] convention that side-effect ticks consume
- gate2_abandon_config.py: CONTROLLER_GATE2_ABANDON_ENABLED kill
  switch (default false). Fresh Phase 2 deploys are audit-only
  until operator explicitly enables; dry_run shared with grooming
  for unified safe-rollout staging
- .opencode/agents/estimator-implementation.md: GATE 2 ABANDON
  section with 9-category criteria + low_value disqualifier ("PR
  cites an issue/ticket -> route to reviewer instead")

Round-2 adversarial-review fixes (all required pre-commit):
- forgejo_writes.close_issue / close_act: NEW cause + event_type
  kwargs (defaults preserve Phase 1 grooming behavior; Phase 2
  callsite overrides). Fixes audit-trail attribution: telemetry
  queries SELECT WHERE cause='estimator_abandon' now return the
  right rows. Phase 1 regression test pins the grooming defaults
- tick.py operator_unstick lookback: dialect-aware json_extract
  fix (Phase 1 carry-over bug; would silently no-op on PostgreSQL)
- grooming_side_effects.py: idempotency filter now keys on
  check_name set (grooming check_names only) so a Phase 1 close
  and a Phase 2 close on the same workflow don't cross-cancel

Tests (+50): TestEstimatorOutputV1Phase2,
TestEstimatorAbandonStateMachine, TestMapEstimatorOutcomePhase2
(including confidence-low downgrade), TestEstimatorSetVerdict
(all 9 categories + cross-field rules), TestEventsHelper,
TestEstimatorAbandonSideEffectTick (including
test_close_writes_estimator_abandon_cause_and_event_type pinning
the audit-trail attribution, and Phase 1 regression guard).
Doc-contract test asserts all 9 categories appear in the agent
prompt. 1509/1509 passing.

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

1193 lines
44 KiB
Python

"""Forgejo write protocol for the master.
The master writes three categories of operator-visible state into
Forgejo:
1. Status comments (verbose what-happened summaries on the PR
timeline)
2. Labels (machine-readable + glanceable state markers)
3. Merge calls (Phase 1d-3c-3, separate module)
Per plan v9 simplified write ordering:
- Forgejo write FIRST with fingerprint-based idempotency
- DB commit SECOND
- Reconciliation tick syncs DB↔Forgejo if a crash happens between
This module ships the controller-side write logic. Forgejo HTTP is
injected via callbacks so tests don't need a live Forgejo instance.
Production wires the callbacks to the existing
``tools/_claim_runtime.get/post`` + ``_status_comments`` helpers.
Fingerprint pattern (carried forward from the existing
``_status_comments`` module): each operator-visible state has a
deterministic fingerprint hash of (workflow_id, event_kind, content).
The comment body includes a hidden HTML marker
``<!-- controller:fingerprint:abc123 -->``. Before posting, we list
existing comments + check for the marker; if found, skip the post.
This gives idempotency without needing the DB to be the source of
truth.
Phase 0 grooming plan (2026-05-24): adds ``close_issue`` and
``defer_issue`` orchestrators that compose the existing primitives
above (post_comment / adjust_labels) with the new ``patch_pr_state``
HTTP primitive AND with DB writes (grooming_decisions audit row +
controller_event row + workflow state transition). See
``.drew/regressions-plan.md`` Phase 0. Per decision #45 these live
here (not in ``forgejo_http.py`` as the plan's literal wording
suggested) because they are orchestration, not pure HTTP — and
``forgejo_writes.py`` is already the orchestration home per its own
docstring.
"""
from __future__ import annotations
import hashlib
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from ..contracts.causes import Cause
logger = logging.getLogger(__name__)
# Fingerprint marker prefix. Searchable + visible-but-quiet in
# Forgejo's UI (HTML comments don't render).
FINGERPRINT_MARKER_PREFIX = "<!-- controller:fingerprint:"
FINGERPRINT_MARKER_SUFFIX = " -->"
# Callback signatures. Each callback returns "what Forgejo said back"
# in a normalized shape.
#
# list_comments(owner, repo, pr_number) -> list of {"body": str, "id": int, ...}
# post_comment(owner, repo, pr_number, body) -> {"id": int, "body": str}
# get_labels(owner, repo, pr_number) -> list of {"name": str, "id": int, ...}
# add_label(owner, repo, pr_number, label_name) -> True on success
# remove_label(owner, repo, pr_number, label_name) -> True on success
ListCommentsCallback = Callable[[str, str, int], list[dict]]
PostCommentCallback = Callable[[str, str, int, str], dict]
GetLabelsCallback = Callable[[str, str, int], list[dict]]
AddLabelCallback = Callable[[str, str, int, str], bool]
RemoveLabelCallback = Callable[[str, str, int, str], bool]
# patch_pr_state(owner, repo, pr_number, state) -> {"status": int, "body": dict}.
# ``state`` is "closed" or "open"; Forgejo treats PRs as issues at this endpoint:
# PATCH /repos/{owner}/{repo}/issues/{n} body={"state": state}.
# Phase 0 grooming plan: powers the close path of close_issue.
PatchPRStateCallback = Callable[[str, str, int, str], dict]
@dataclass
class StatusCommentResult:
"""What ``post_status_comment`` returns.
Three outcomes:
- posted: a new comment was successfully posted.
- duplicate: a comment with the same fingerprint already exists;
this call was a no-op (idempotency working as intended).
- failed: the post-comment callback raised; caller decides
whether to retry on next tick.
"""
status: str # 'posted' | 'duplicate' | 'failed'
comment_id: int | None = None
fingerprint: str | None = None
error: str | None = None
@dataclass
class LabelAdjustResult:
"""Summary of one label adjust (set OR unset)."""
label: str
action: str # 'added' | 'removed' | 'no-op' | 'failed'
error: str | None = None
# ─── fingerprint helpers ──────────────────────────────────────────────
def compute_fingerprint(
*,
workflow_id: int,
event_kind: str,
content_key: str,
) -> str:
"""Build the deterministic fingerprint for a write.
The fingerprint is a short SHA256 prefix of
(workflow_id, event_kind, content_key) — enough to disambiguate
same-workflow / same-event-kind writes that differ only in content
(e.g., two STUCK transitions with different reasons).
"""
payload = f"{workflow_id}:{event_kind}:{content_key}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def build_marker(fingerprint: str) -> str:
"""Build the HTML marker the comment body must include."""
return f"{FINGERPRINT_MARKER_PREFIX}{fingerprint}{FINGERPRINT_MARKER_SUFFIX}"
def comment_has_fingerprint(comment_body: str, fingerprint: str) -> bool:
"""Check if a comment's body contains the marker for this fingerprint."""
return build_marker(fingerprint) in (comment_body or "")
# ─── status comment ───────────────────────────────────────────────────
def post_status_comment(
*,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
event_kind: str,
content_key: str,
body_text: str,
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
) -> StatusCommentResult:
"""Post a status comment with fingerprint-based idempotency.
Procedure:
1. Build the fingerprint from (workflow_id, event_kind, content_key).
2. Build the marker + check existing comments for it (via
``list_comments`` callback).
3. If a matching comment exists → return duplicate (no-op).
4. Else post a new comment with body = body_text + marker.
Failure of ``post_comment`` is returned as ``StatusCommentResult(
status='failed', error=...)`` — the caller decides whether to
retry on the next tick. ``list_comments`` failure falls through:
we conservatively post (risk: occasional duplicate; mitigated by
fingerprint match on next sweep).
"""
fingerprint = compute_fingerprint(
workflow_id=workflow_id,
event_kind=event_kind,
content_key=content_key,
)
marker = build_marker(fingerprint)
try:
existing = list_comments(owner, repo, pr_number) or []
except Exception as exc: # noqa: BLE001 — Forgejo flake; fall through to post
logger.warning(
"list_comments failed for #%d (%s); will post (risks duplicate): %s",
pr_number,
repo,
exc,
)
existing = []
for c in existing:
if comment_has_fingerprint(c.get("body") or "", fingerprint):
return StatusCommentResult(
status="duplicate",
comment_id=c.get("id"),
fingerprint=fingerprint,
)
body = f"{body_text}\n\n{marker}"
try:
posted = post_comment(owner, repo, pr_number, body)
except Exception as exc: # noqa: BLE001
return StatusCommentResult(
status="failed",
fingerprint=fingerprint,
error=str(exc),
)
return StatusCommentResult(
status="posted",
comment_id=(posted or {}).get("id"),
fingerprint=fingerprint,
)
# ─── labels ───────────────────────────────────────────────────────────
def adjust_labels(
*,
owner: str,
repo: str,
pr_number: int,
add: list[str] | None = None,
remove: list[str] | None = None,
get_labels: GetLabelsCallback,
add_label: AddLabelCallback,
remove_label: RemoveLabelCallback,
) -> list[LabelAdjustResult]:
"""Add + remove labels with no-op-on-already-set/missing.
Procedure:
1. List the PR's current labels (via ``get_labels``).
2. For each label in ``add``: if already present → no-op; else
add via ``add_label``.
3. For each label in ``remove``: if not present → no-op; else
remove via ``remove_label``.
Returns one LabelAdjustResult per label across both lists.
Failures are per-label (a failed add doesn't prevent a remove
of a different label).
"""
add = add or []
remove = remove or []
if not add and not remove:
return []
try:
current_labels = get_labels(owner, repo, pr_number) or []
except Exception as exc: # noqa: BLE001
# Defensive: if we can't list, mark every requested action
# as failed so the caller can retry on next tick.
err_msg = f"get_labels failed: {exc}"
return [
LabelAdjustResult(label=lbl, action="failed", error=err_msg)
for lbl in (add + remove)
]
current_names = {(c.get("name") or "") for c in current_labels}
results: list[LabelAdjustResult] = []
for lbl in add:
if lbl in current_names:
results.append(LabelAdjustResult(label=lbl, action="no-op"))
continue
try:
ok = add_label(owner, repo, pr_number, lbl)
results.append(
LabelAdjustResult(
label=lbl,
action="added" if ok else "failed",
error=None if ok else "add_label returned False",
)
)
except Exception as exc: # noqa: BLE001
results.append(
LabelAdjustResult(
label=lbl,
action="failed",
error=str(exc),
)
)
for lbl in remove:
if lbl not in current_names:
results.append(LabelAdjustResult(label=lbl, action="no-op"))
continue
try:
ok = remove_label(owner, repo, pr_number, lbl)
results.append(
LabelAdjustResult(
label=lbl,
action="removed" if ok else "failed",
error=None if ok else "remove_label returned False",
)
)
except Exception as exc: # noqa: BLE001
results.append(
LabelAdjustResult(
label=lbl,
action="failed",
error=str(exc),
)
)
return results
# ─── Phase 0 grooming: close + defer orchestrators ────────────────────
@dataclass
class CloseResult:
"""What ``close_issue`` returns.
Status values:
- ``'closed'`` — Forgejo PATCH returned 200/201; PR is now closed.
- ``'already-closed'`` — Forgejo PATCH returned 404 (PR already gone);
treated as no-op success.
- ``'dry-run'`` — caller passed ``dry_run=True``; audit row written
with ``executed=0``, no Forgejo call made.
- ``'pending-retry'`` — transient failure (timeout, 429, 5xx,
comment-post error). The forgejo_write_pending=1 row remains; a
later sweep should re-invoke close_issue with the same arguments.
- ``'failed'`` — non-retryable 4xx (400/401/403/422). Caller should
surface to the operator; replay_attempts increment is the caller's
responsibility (Phase 1's retry sweep).
"""
status: str
decision_id: int | None = None
fingerprint: str | None = None
forgejo_status: int | None = None
error: str | None = None
@dataclass
class DeferResult:
"""What ``defer_issue`` returns. Same shape as ``CloseResult``;
'closed' is replaced by 'deferred', 'already-closed' by
'already-deferred' (when the audit row + workflow state already
show this defer has been applied)."""
status: str
decision_id: int | None = None
fingerprint: str | None = None
forgejo_status: int | None = None
error: str | None = None
def _classify_forgejo_status(http_status: int) -> tuple[str, str | None]:
"""Map an HTTP status to (result_status, error_or_None).
Used by both close_issue's PATCH and defer_issue's add/remove-label
error paths. Centralized so the error-handling matrix from the
plan is in one place.
"""
if http_status in (200, 201):
return ("ok", None)
if http_status == 404:
# Idempotency: already gone is success-no-op.
return ("ok-no-op", None)
if http_status == 429:
return ("pending-retry", "rate-limited")
if 400 <= http_status < 500:
return ("failed", f"client error {http_status}")
if http_status >= 500:
return ("pending-retry", f"server error {http_status}")
return ("failed", f"unexpected status {http_status}")
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _assert_outside_txn(session: Session, fn_name: str) -> None:
"""Phase 0 contract (decision #31): close_issue / defer_issue MUST
be called outside any existing DB transaction. They manage their
own atomic transaction for audit-row + event-row + workflow-update
atomicity. Calling inside an existing txn would tie the orchestrator's
rollback semantics to the caller's transactional state, defeating the
crash-safe protocol's atomicity guarantee.
Fails loud rather than silently producing weird partial-commit
behavior.
"""
if session.in_transaction():
raise RuntimeError(
f"{fn_name} must be called outside any existing transaction; "
"it manages its own transaction for audit-row + event-row + "
"workflow-update atomicity."
)
def close_issue(
*,
session: Session,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
# grooming_decisions audit fields
check_name: str,
stage: str,
reason_category: str,
target_workflow_id: int | None = None,
confidence: str | None = None,
llm_reasoning: str | None = None,
suspicion_score: float | None = None,
loser_head_sha_at_decision: str | None = None,
# comment template fields
gate: str,
explanation: str,
# HTTP callbacks
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
patch_pr_state: PatchPRStateCallback,
# Mode
dry_run: bool = False,
now: Callable[[], datetime] | None = None,
# Phase 2 (2026-05-25) — controller_events row attribution.
# Defaults preserve Phase 1 grooming-close behavior; Phase 2's
# estimator-abandon path overrides both to write
# ``cause=Cause.ESTIMATOR_ABANDON`` + ``event_type='estimator_abandon'``.
# The dedup query that looks up an existing event row for retry
# also keys on ``event_type``, so the override is what makes Phase
# 1 and Phase 2 closes for the same workflow correctly tracked as
# SEPARATE audit-trail entries instead of one squashing the other.
cause: "Cause | None" = None,
event_type: str = "grooming_abandon",
# Private (underscore-prefixed) — public callers SHOULD use
# ``close_decide_and_act`` (True) or ``close_act`` (False) which
# set this implicitly. When False, txn 1 SKIPS the workflow-state
# mutation; the state machine has already applied
# ``apply_event(GROOMING, groom_verdict_close)`` via ``tick.py``
# and the workflow is already ABANDONED. When True (Phase 0 /
# operator path), this function owns the transition. Marked
# private to nudge callers toward the named wrappers, which can't
# be confused about which semantic they want.
_apply_workflow_transition: bool = True,
) -> CloseResult:
"""Phase 0 grooming-plan close orchestrator. Implements the
crash-safe protocol from the plan:
1. In one DB transaction: INSERT grooming_decisions audit row +
INSERT controller_event row (event_type='grooming_abandon',
cause='grooming_close', forgejo_write_pending=1) + UPDATE
workflows (current_state='ABANDONED', grooming_evaluated_at).
The workflow transition inside the txn ensures reconciliation's
PR-state polling won't write a duplicate 'external-close' event
(it will see current_state == target_state and short-circuit).
2. Render the close comment template with decision_id from step 1.
3. POST audit comment via fingerprint-deduped post_status_comment.
4. PATCH state:'closed' on Forgejo.
5. UPDATE controller_event: forgejo_write_pending=0, populate
forgejo_result; UPDATE grooming_decisions.executed=1.
On any failure between steps 1 and 5, the event row stays at
forgejo_write_pending=1; a later sweep (Phase 1 deliverable) can
re-invoke ``close_issue`` with the same arguments to resume. The
audit-row dedup check at the top of this function makes the re-
invocation skip the txn-1 insert.
The plan's "human-closed guard" was DROPPED in decision #41 — Forgejo's
PR detail response does not include a ``closed_by`` field, so the
timeline-API alternative was the only path, and the cosmetic-
misattribution risk on the rare crash-during-retry race was judged
acceptable.
"""
_assert_outside_txn(session, "close_issue")
# Avoid module-level model imports to keep forgejo_writes import-time
# cheap (the rest of the module is dependency-free); also avoids any
# latent circular-import risk with code paths that import
# forgejo_writes before db.models is ready.
from ..contracts.causes import Cause
from ..db.models import ControllerEvent, GroomingDecision, Workflow
from sqlalchemy import select
# Resolve the cause now that Cause is in scope. Defaulting in the
# body (vs the signature) avoids the circular-import lift to make
# Cause a module-top import.
effective_cause = cause if cause is not None else Cause.GROOMING_CLOSE
now_fn = now or _now_utc
fingerprint = compute_fingerprint(
workflow_id=workflow_id,
event_kind="grooming-close",
content_key=f"close:{check_name}:{stage}:{reason_category}",
)
# Idempotency check + txn 1.
decision_id: int | None = None
event_id: int | None = None
with session.begin():
# Dedup: a same-fingerprint audit row already exists →
# this is a retry of a previously-started close. Reuse the
# decision_id; the txn-1 mutations should already be applied.
existing = session.execute(
select(GroomingDecision)
.where(
GroomingDecision.workflow_id == workflow_id,
GroomingDecision.verdict == "close",
GroomingDecision.check_name == check_name,
GroomingDecision.reason_category == reason_category,
)
.order_by(GroomingDecision.decided_at.desc())
.limit(1)
).scalar_one_or_none()
if existing is not None:
decision_id = existing.decision_id
# Find the matching pending event for clear-pending in
# step 5. Filter on the SAME event_type the original
# write used — Phase 2's estimator-abandon path writes
# ``event_type='estimator_abandon'`` so a Phase-1-shaped
# query for ``'grooming_abandon'`` would miss it and
# leave the row at ``forgejo_write_pending=1``.
existing_ev = session.execute(
select(ControllerEvent)
.where(
ControllerEvent.workflow_id == workflow_id,
ControllerEvent.event_type == event_type,
ControllerEvent.forgejo_fingerprint == fingerprint,
)
.order_by(ControllerEvent.ts.desc())
.limit(1)
).scalar_one_or_none()
if existing_ev is not None:
event_id = existing_ev.event_id
else:
now_val = now_fn()
row = GroomingDecision(
workflow_id=workflow_id,
decided_at=now_val,
check_name=check_name,
stage=stage,
verdict="close",
reason_category=reason_category,
target_workflow_id=target_workflow_id,
confidence=confidence,
llm_reasoning=llm_reasoning,
suspicion_score=suspicion_score,
loser_head_sha_at_decision=loser_head_sha_at_decision,
executed=0,
)
session.add(row)
session.flush()
decision_id = row.decision_id
wf = session.get(Workflow, workflow_id)
if wf is None:
# Surfaces caller bugs immediately.
raise RuntimeError(
f"close_issue: workflow {workflow_id} not found in DB"
)
ev = ControllerEvent(
workflow_id=workflow_id,
ts=now_val,
event_type=event_type,
from_state=wf.current_state,
to_state="ABANDONED",
cause=effective_cause,
forgejo_write_pending=True,
forgejo_fingerprint=fingerprint,
payload={
"check_name": check_name,
"stage": stage,
"reason_category": reason_category,
"decision_id": decision_id,
},
)
session.add(ev)
session.flush()
event_id = ev.event_id
# Workflow transition: → ABANDONED. Direct UPDATE matches
# the reconciliation pattern (see
# reconciliation._apply_transition). The Phase 1 corrected
# dispatch (worker-shape) skips this block via
# ``apply_workflow_transition=False``: the state machine
# has already moved the workflow to ABANDONED via
# ``apply_event(GROOMING, groom_verdict_close)`` before
# ``run_grooming_side_effects_tick`` calls back into this
# function. ``grooming_evaluated_at`` is updated either
# way — it timestamps the grooming decision and is
# workflow-state-independent.
if _apply_workflow_transition:
wf.current_state = "ABANDONED"
wf.entered_state_at = now_val
wf.last_transition_at = now_val
wf.grooming_evaluated_at = now_val
# Dry-run short-circuit: txn 1 above wrote the audit row with
# executed=0; no Forgejo calls.
if dry_run:
return CloseResult(
status="dry-run",
decision_id=decision_id,
fingerprint=fingerprint,
)
# Step 2: render the audit comment.
from .audit_comments import CLOSE_COMMENT_TEMPLATE, render_comment_template
filled = CLOSE_COMMENT_TEMPLATE.format(
gate=gate,
reason_category=reason_category,
explanation=explanation,
canonical_pr_line=(
f"- Canonical (if duplicate): #{target_workflow_id}"
if target_workflow_id is not None
else ""
),
confidence_line=(
f"- LLM confidence (when applicable): {confidence}"
if confidence is not None
else ""
),
reasoning_line=(
f"- LLM reasoning (when applicable): {llm_reasoning}"
if llm_reasoning is not None
else ""
),
)
if decision_id is None:
# Defensive: should never happen — either txn 1 inserted or the
# idempotency branch loaded an existing decision_id.
raise RuntimeError("close_issue: decision_id is None after txn 1")
rendered = render_comment_template(filled, decision_id=decision_id)
# Step 3: post the comment (fingerprint-deduped — safe to retry).
comment_result = post_status_comment(
owner=owner,
repo=repo,
pr_number=pr_number,
workflow_id=workflow_id,
event_kind="grooming-close",
content_key=f"close:{check_name}:{stage}:{reason_category}",
body_text=rendered,
list_comments=list_comments,
post_comment=post_comment,
)
if comment_result.status == "failed":
return CloseResult(
status="pending-retry",
decision_id=decision_id,
fingerprint=fingerprint,
error=f"comment post failed: {comment_result.error}",
)
# Step 4: PATCH state:closed.
try:
resp = patch_pr_state(owner, repo, pr_number, "closed")
except Exception as exc: # noqa: BLE001 — network/timeout: retry next sweep.
return CloseResult(
status="pending-retry",
decision_id=decision_id,
fingerprint=fingerprint,
error=f"patch_pr_state raised: {exc}",
)
forgejo_status = int((resp or {}).get("status") or 0)
category, err = _classify_forgejo_status(forgejo_status)
if category == "pending-retry":
return CloseResult(
status="pending-retry",
decision_id=decision_id,
fingerprint=fingerprint,
forgejo_status=forgejo_status,
error=err,
)
if category == "failed":
return CloseResult(
status="failed",
decision_id=decision_id,
fingerprint=fingerprint,
forgejo_status=forgejo_status,
error=err or f"patch returned {forgejo_status}: {(resp or {}).get('body')}",
)
# category == 'ok' or 'ok-no-op'
result_status = "already-closed" if category == "ok-no-op" else "closed"
# Step 5: clear forgejo_write_pending + mark executed.
if event_id is not None:
with session.begin():
ev = session.get(ControllerEvent, event_id)
if ev is not None:
ev.forgejo_write_pending = False
ev.forgejo_result = {
"patch_status": forgejo_status,
"comment_status": comment_result.status,
"comment_id": comment_result.comment_id,
}
gd = session.get(GroomingDecision, decision_id)
if gd is not None:
gd.executed = 1
gd.forgejo_response = {
"patch_status": forgejo_status,
"comment_status": comment_result.status,
}
return CloseResult(
status=result_status,
decision_id=decision_id,
fingerprint=fingerprint,
forgejo_status=forgejo_status,
)
def defer_issue(
*,
session: Session,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
# grooming_decisions audit fields
check_name: str,
stage: str,
reason_category: str,
target_workflow_id: int | None = None,
confidence: str | None = None,
llm_reasoning: str | None = None,
preserved_value: str | None = None,
suspicion_score: float | None = None,
loser_head_sha_at_decision: str | None = None,
# comment template fields
gate: str,
canonical_pr_number: int | None = None,
# defer-specific
deferred_reason: str = "duplication",
new_label: str = "auto/needs-reevaluation",
remove_label: str = "auto/sentinel",
# HTTP callbacks
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
get_labels: GetLabelsCallback,
add_label: AddLabelCallback,
remove_label_cb: RemoveLabelCallback,
# Mode
dry_run: bool = False,
now: Callable[[], datetime] | None = None,
# Private — public callers SHOULD use ``defer_decide_and_act``
# (True) or ``defer_act`` (False); see ``close_issue``'s same-named
# kwarg for full semantics. When False, the workflow-state
# mutation + ``pre_pause_state`` capture are SKIPPED (state
# machine fired ``groom_verdict_defer`` → PAUSED already);
# ``deferred_reason`` / ``deferred_at`` / ``deferred_target_workflow_id``
# columns are written either way (decision context, not state).
_apply_workflow_transition: bool = True,
) -> DeferResult:
"""Phase 0 grooming-plan defer orchestrator (state-based dedup
pattern — decisions #22 + #42).
Procedure (one txn for state + audit + event, HTTP work after,
one txn for clear-pending):
1. In one DB transaction:
- INSERT grooming_decisions audit row.
- INSERT controller_event row with event_type='label-pause',
cause='grooming_defer', forgejo_write_pending=1.
- UPDATE workflows: current_state='PAUSED' (with pre_pause_state
capture), deferred_reason / deferred_at / deferred_target_workflow_id,
grooming_evaluated_at.
The state transition inside the txn ensures reconciliation's
pause-detection clause (`current != 'PAUSED'`) skips on the next
tick, AND the RESUME guard (Phase 1 will add ``and
deferred_reason IS NULL``) prevents un-pause during the pre-PATCH
window where Forgejo still shows the label.
2. Render the defer comment template with decision_id.
3. POST audit comment.
4. Remove auto/sentinel + add auto/needs-reevaluation on Forgejo.
5. UPDATE controller_event: forgejo_write_pending=0; UPDATE
grooming_decisions.executed=1.
Scheduler skips deferred workflows via the same ``deferred_reason
IS NOT NULL`` filter Phase 1 will add to scheduler.py — even if
auto/sentinel is re-added later. Resume requires both the
deferred_reason clear AND the label restore (the two AND-gated
blocks design).
"""
_assert_outside_txn(session, "defer_issue")
from ..contracts.causes import Cause
from ..db.models import ControllerEvent, GroomingDecision, Workflow
from sqlalchemy import select
now_fn = now or _now_utc
fingerprint = compute_fingerprint(
workflow_id=workflow_id,
event_kind="grooming-defer",
content_key=f"defer:{check_name}:{stage}:{reason_category}",
)
decision_id: int | None = None
event_id: int | None = None
with session.begin():
# Idempotency: existing defer audit row for same fingerprint →
# this is a retry, reuse the decision_id; state transition
# already happened on the original call.
existing = session.execute(
select(GroomingDecision)
.where(
GroomingDecision.workflow_id == workflow_id,
GroomingDecision.verdict == "defer",
GroomingDecision.check_name == check_name,
GroomingDecision.reason_category == reason_category,
)
.order_by(GroomingDecision.decided_at.desc())
.limit(1)
).scalar_one_or_none()
if existing is not None:
decision_id = existing.decision_id
existing_ev = session.execute(
select(ControllerEvent)
.where(
ControllerEvent.workflow_id == workflow_id,
ControllerEvent.event_type == "label-pause",
ControllerEvent.forgejo_fingerprint == fingerprint,
)
.order_by(ControllerEvent.ts.desc())
.limit(1)
).scalar_one_or_none()
if existing_ev is not None:
event_id = existing_ev.event_id
else:
now_val = now_fn()
row = GroomingDecision(
workflow_id=workflow_id,
decided_at=now_val,
check_name=check_name,
stage=stage,
verdict="defer",
reason_category=reason_category,
target_workflow_id=target_workflow_id,
confidence=confidence,
llm_reasoning=llm_reasoning,
preserved_value=preserved_value,
suspicion_score=suspicion_score,
loser_head_sha_at_decision=loser_head_sha_at_decision,
executed=0,
)
session.add(row)
session.flush()
decision_id = row.decision_id
wf = session.get(Workflow, workflow_id)
if wf is None:
raise RuntimeError(
f"defer_issue: workflow {workflow_id} not found in DB"
)
ev = ControllerEvent(
workflow_id=workflow_id,
ts=now_val,
event_type="label-pause",
from_state=wf.current_state,
to_state="PAUSED",
cause=Cause.GROOMING_DEFER,
forgejo_write_pending=True,
forgejo_fingerprint=fingerprint,
payload={
"check_name": check_name,
"stage": stage,
"reason_category": reason_category,
"decision_id": decision_id,
"deferred_reason": deferred_reason,
},
)
session.add(ev)
session.flush()
event_id = ev.event_id
# Capture pre_pause_state ONLY if the workflow isn't
# already paused — preserves the original pause origin if a
# human had already pulled the label. The Phase 1
# corrected dispatch skips the state mutation (state
# machine already transitioned to PAUSED via
# ``groom_verdict_defer``); the decision-context columns
# (deferred_reason / deferred_at / target /
# grooming_evaluated_at) are written either way.
if _apply_workflow_transition:
if wf.current_state != "PAUSED":
wf.pre_pause_state = wf.current_state
wf.current_state = "PAUSED"
wf.entered_state_at = now_val
wf.last_transition_at = now_val
wf.grooming_evaluated_at = now_val
wf.deferred_reason = deferred_reason
wf.deferred_at = now_val
wf.deferred_target_workflow_id = target_workflow_id
if dry_run:
return DeferResult(
status="dry-run",
decision_id=decision_id,
fingerprint=fingerprint,
)
# Step 2: render comment.
from .audit_comments import DEFER_COMMENT_TEMPLATE, render_comment_template
if decision_id is None:
raise RuntimeError("defer_issue: decision_id is None after txn 1")
filled = DEFER_COMMENT_TEMPLATE.format(
gate=gate,
reason_category=reason_category,
canonical_pr_number=(
canonical_pr_number if canonical_pr_number is not None else "-"
),
confidence=confidence if confidence is not None else "-",
reasoning=llm_reasoning if llm_reasoning is not None else "-",
preserved_value_line=(
f"- Preserved value (when applicable): {preserved_value}"
if preserved_value is not None
else ""
),
workflow_id=workflow_id,
)
rendered = render_comment_template(filled, decision_id=decision_id)
# Step 3: post comment.
comment_result = post_status_comment(
owner=owner,
repo=repo,
pr_number=pr_number,
workflow_id=workflow_id,
event_kind="grooming-defer",
content_key=f"defer:{check_name}:{stage}:{reason_category}",
body_text=rendered,
list_comments=list_comments,
post_comment=post_comment,
)
if comment_result.status == "failed":
return DeferResult(
status="pending-retry",
decision_id=decision_id,
fingerprint=fingerprint,
error=f"comment post failed: {comment_result.error}",
)
# Step 4: label swap. adjust_labels handles no-op cases (already
# present/missing). Per-label failures are surfaced so we can
# pending-retry if either side failed.
label_results = adjust_labels(
owner=owner,
repo=repo,
pr_number=pr_number,
add=[new_label],
remove=[remove_label],
get_labels=get_labels,
add_label=add_label,
remove_label=remove_label_cb,
)
failed_labels = [r for r in label_results if r.action == "failed"]
if failed_labels:
# Don't clear pending; let a sweep retry.
return DeferResult(
status="pending-retry",
decision_id=decision_id,
fingerprint=fingerprint,
error=(
"label swap had failures: "
+ ", ".join(f"{r.label}:{r.error}" for r in failed_labels)
),
)
# Step 5: clear forgejo_write_pending + mark executed.
if event_id is not None:
with session.begin():
ev = session.get(ControllerEvent, event_id)
if ev is not None:
ev.forgejo_write_pending = False
ev.forgejo_result = {
"comment_status": comment_result.status,
"comment_id": comment_result.comment_id,
"label_actions": [
{"label": r.label, "action": r.action}
for r in label_results
],
}
gd = session.get(GroomingDecision, decision_id)
if gd is not None:
gd.executed = 1
gd.forgejo_response = {
"comment_status": comment_result.status,
"label_actions": [
{"label": r.label, "action": r.action}
for r in label_results
],
}
return DeferResult(
status="deferred",
decision_id=decision_id,
fingerprint=fingerprint,
)
# Phase 1 corrected dispatch (2026-05-25) — explicit named entry-points
# for the two callers of the same underlying orchestrator. Each public
# function has a full keyword-only signature (no **kwargs) so IDE
# type-checking + introspection work the same as on ``close_issue`` /
# ``defer_issue`` themselves.
#
# close_decide_and_act / defer_decide_and_act
# Phase 0 semantics: decide (open the txn, write audit row +
# event row, mutate workflow state) AND act (Forgejo writes,
# clear pending). Use when the call site is the sole owner of
# the state transition — operator scripts, the legacy
# synchronous path. Aliases to ``close_issue`` / ``defer_issue``
# for back-compat with the Phase 0 test suite.
#
# close_act / defer_act
# Worker-shape semantics: the state machine already fired the
# verdict event (``groom_verdict_close`` / ``groom_verdict_defer``)
# and transitioned the workflow. This variant writes the audit
# row + event row + Forgejo writes WITHOUT re-mutating workflow
# state. Used by ``run_grooming_side_effects_tick``.
#
# Both pairs delegate to the shared ``close_issue`` / ``defer_issue``
# bodies via the private ``_apply_workflow_transition`` kwarg. The
# bool itself is module-private; callers SHOULD pick the named entry-
# point that matches their intent and let the wrapper set the flag.
# The architect's Phase-1 review noted that a future refactor should
# split the shared body into truly separate primitives (see deferred
# backlog in ``.drew/regressions-plan.md``); this interim shape avoids
# that work while still giving the two semantics distinct public names.
close_decide_and_act = close_issue
defer_decide_and_act = defer_issue
def close_act(
*,
session: Session,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
check_name: str,
stage: str,
reason_category: str,
target_workflow_id: int | None = None,
confidence: str | None = None,
llm_reasoning: str | None = None,
suspicion_score: float | None = None,
loser_head_sha_at_decision: str | None = None,
gate: str,
explanation: str,
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
patch_pr_state: PatchPRStateCallback,
dry_run: bool = False,
now: Callable[[], datetime] | None = None,
cause: "Cause | None" = None,
event_type: str = "grooming_abandon",
) -> CloseResult:
"""Phase 1 worker-shape close: Forgejo writes + audit row only;
the workflow-state transition has already been applied by the
state machine via ``apply_event(GROOMING, groom_verdict_close)``.
Signature mirrors ``close_issue`` exactly EXCEPT no
``_apply_workflow_transition`` parameter — this entry-point pins
it to False. Use ``close_decide_and_act`` when your caller is the
sole owner of the state transition (operator scripts, Phase 0
legacy path).
Phase 2 callers (estimator-abandon) MUST pass
``cause=Cause.ESTIMATOR_ABANDON`` + ``event_type='estimator_abandon'``
so the audit-trail attribution is correct; Phase 1 grooming callers
accept the defaults (cause=Cause.GROOMING_CLOSE,
event_type='grooming_abandon').
"""
return close_issue(
session=session,
owner=owner,
repo=repo,
pr_number=pr_number,
workflow_id=workflow_id,
check_name=check_name,
stage=stage,
reason_category=reason_category,
target_workflow_id=target_workflow_id,
confidence=confidence,
llm_reasoning=llm_reasoning,
suspicion_score=suspicion_score,
loser_head_sha_at_decision=loser_head_sha_at_decision,
gate=gate,
explanation=explanation,
list_comments=list_comments,
post_comment=post_comment,
patch_pr_state=patch_pr_state,
dry_run=dry_run,
now=now,
cause=cause,
event_type=event_type,
_apply_workflow_transition=False,
)
def defer_act(
*,
session: Session,
owner: str,
repo: str,
pr_number: int,
workflow_id: int,
check_name: str,
stage: str,
reason_category: str,
target_workflow_id: int | None = None,
confidence: str | None = None,
llm_reasoning: str | None = None,
preserved_value: str | None = None,
suspicion_score: float | None = None,
loser_head_sha_at_decision: str | None = None,
gate: str,
canonical_pr_number: int | None = None,
deferred_reason: str = "duplication",
new_label: str = "auto/needs-reevaluation",
remove_label: str = "auto/sentinel",
list_comments: ListCommentsCallback,
post_comment: PostCommentCallback,
get_labels: GetLabelsCallback,
add_label: AddLabelCallback,
remove_label_cb: RemoveLabelCallback,
dry_run: bool = False,
now: Callable[[], datetime] | None = None,
) -> DeferResult:
"""Phase 1 worker-shape defer: Forgejo writes + audit row only;
the workflow-state transition has already been applied by the
state machine via ``apply_event(GROOMING, groom_verdict_defer)``.
Signature mirrors ``defer_issue`` exactly EXCEPT no
``_apply_workflow_transition`` parameter — this entry-point pins
it to False. Use ``defer_decide_and_act`` when your caller owns
the state transition (operator scripts, Phase 0 legacy path).
"""
return defer_issue(
session=session,
owner=owner,
repo=repo,
pr_number=pr_number,
workflow_id=workflow_id,
check_name=check_name,
stage=stage,
reason_category=reason_category,
target_workflow_id=target_workflow_id,
confidence=confidence,
llm_reasoning=llm_reasoning,
preserved_value=preserved_value,
suspicion_score=suspicion_score,
loser_head_sha_at_decision=loser_head_sha_at_decision,
gate=gate,
canonical_pr_number=canonical_pr_number,
deferred_reason=deferred_reason,
new_label=new_label,
remove_label=remove_label,
list_comments=list_comments,
post_comment=post_comment,
get_labels=get_labels,
add_label=add_label,
remove_label_cb=remove_label_cb,
dry_run=dry_run,
now=now,
_apply_workflow_transition=False,
)
__all__ = [
"AddLabelCallback",
"CloseResult",
"DeferResult",
"FINGERPRINT_MARKER_PREFIX",
"FINGERPRINT_MARKER_SUFFIX",
"GetLabelsCallback",
"LabelAdjustResult",
"ListCommentsCallback",
"PatchPRStateCallback",
"PostCommentCallback",
"RemoveLabelCallback",
"StatusCommentResult",
"adjust_labels",
"build_marker",
"close_act",
"close_decide_and_act",
"close_issue",
"comment_has_fingerprint",
"compute_fingerprint",
"defer_act",
"defer_decide_and_act",
"defer_issue",
"post_status_comment",
]