Files
cleveragents-core/tools/controller/master/ci_status_poll.py
T
drew 84da774212 fix(controller): batch M — post-Phase-1m adversarial review fixes
Three rounds of adversarial review (Chief Architect / Principal Dev /
Senior Test Engineer) on commits 3ca794be7..db12f45ac surfaced ~35
issues. This commit addresses 25+ across criticals, highs, and
mediums, and adds 40 new tests covering the changes plus key gaps the
review identified.

CRITICALS (M1):
- CA1: stale {role}_output.json from a prior attempt on the same
  per-PR workspace was readable as "fresh" output of the new attempt.
  agent_runner now unlinks the MCP-canonical path AND every fallback
  path BEFORE the session runs.
- CA2/PD5: opencode.json-registered MCP subprocesses persist across
  OpenCode sessions, but BuilderState was module-singleton. Added
  reset_for_new_attempt() + cross-session detection (compare
  identity.attempt_id) to every *_start; force-resets with WARN if
  prior attempt was interrupted (timeout / lost lock).
- PD3: inline-JSON callback could overwrite an MCP-written canonical
  V1 file with adapted-from-prose garbage. Callback now inspects
  existing files and skips when V1 is already present.
- PD4: FORGEJO_URL = .rstrip("/api/v1") is a character-set strip —
  catastrophic for hosts whose path contains /v1 in the middle.
  Replaced with explicit endswith()-based suffix strip.
- CA10: clone URL embedded $FORGEJO_TOKEN, persisted into
  .git/config where any agent could cat it. Token now sourced via
  local credential.helper at clone-time, URL kept clean.
- CA12: state.finalized was set BEFORE the file write, so disk-full
  / OSError left the agent unable to retry finalize. Reordered.

HIGHS (M2):
- CA3/PD12: output_path validation (NUL-byte rejection, must be
  absolute, parent-not-file check) in finalize_and_emit.
- CA6: ci_status_poll SELECT only considered implementer attempts;
  conflict_resolver also pushes commits. SQL now unions both roles.
- PD9: ci_status_poll could advance on a stale "resolved" SHA from a
  blocked attempt (whose head_sha_after == head_sha_before). Added
  outcome='resolved' filter.
- CA8: cancelled/stale CI states mapped to ci_red_retry_same_tier,
  burning pickup_count on healthy PRs. Both now wait (treated as
  operator/system action, not failure). timed_out stays red.
- TE9: unknown Forgejo CI states now WARN-log instead of silently
  being treated as pending — operators see new state strings.
- PD8: ci_status_poll event_type strings standardized to match the
  state-machine event names (ci_green / ci_red_retry_same_tier)
  instead of legacy ci-green / ci-red.
- CA7: inline-JSON callback now checks lost_lock_check BEFORE write
  so a file isn't staged after lock loss.
- PD10: atomic .tmp + os.replace writes in both MCP finalize and
  inline callback so the poller never sees a half-written file.
- PD16: inline_output_callback exceptions now re-raise as WorkerError
  instead of being silently logged (root cause was buried 30s later
  in a canonical-output timeout).
- CA9: WorkerConfig manual rebuild on --max-concurrent/--poll-interval
  silently dropped new fields. Use dataclasses.replace, matching
  round-4 P5 fix in master/__main__.py.

MEDIUMS (M3) — legacy_adapter quality upgrades:
- PD1: unrecognized confidence values now WARN instead of silently
  defaulting to "medium" — surfaces agent prompt drift.
- PD2: estimator recommended_tier clamped to {0,1,2} so an out-of-
  range int doesn't bypass the adapter's whole purpose.
- PD7: reviewer blocking_issues list-of-strings coerced into the
  list-of-BlockingIssue-dict shape strict_parse requires.
- PD13: conflict_resolver prompt defaults tier=1 + warns instead of
  raising; the scheduler always sets it but defends against drift.
- PD14: summarizer summary < 50 chars padded with a clear marker so
  strict_parse accepts it (and the truncation is visible).
- PD15: implementer blockers capped at 4096 chars each so a buggy
  agent can't blow up audit log / DB column.
- PD17: launch script accepts either FORGEJO_TOKEN or GITEA_TOKEN
  with a clear error if both are unset.
- PD22: conflict_resolver adapter accepts singular commit_sha
  fallback, matching implementer.
- CA4: every adapter invocation logs role + payload key fingerprint
  so operators can measure agent-migration progress.
- estimator + summarizer now have explicit _start tools (the prompts
  already referenced them; previously absent → first call would fail).

TESTS (M4) — added 40 tests in test_post_review_fixes.py:
- Cross-session MCP state reset (implementer + reviewer + estimator
  + summarizer; intra-session double-start still rejected).
- finalize_and_emit output_path precedence (arg > env > stdout),
  parent-dir creation, rejection of relative/NUL paths, failed-write
  leaves state retryable.
- legacy_adapter quality: tier clamping, blocker cap, non-string
  commit warning, blocking_issues string coercion, conflict_resolver
  full roundtrip + non-resolved head clearing, summarizer padding,
  confidence warning, V1-passthrough no-log.
- opencode.json registration parity: every MCP the prompts name is
  registered with the correct module path.
- Per-role prompts mention {role}_output.json (canonical poller path)
  + the "DO NOT emit chat-JSON" directive.
- FORGEJO_URL suffix-strip parametrized table.
- agent_runner stale-file cleanup: prior-attempt file is unlinked
  before a new session can read it as phantom output.

Also updated 2 pre-existing tests for the CA8 / PD8 / PD13 behavior
changes (cancelled→wait, event_type renaming, conflict_resolver
default-tier warning).

Total: 741 → 781 tests, 0 regressions.

DEFERRED (M5 follow-up — non-trial-blocking):
- CA5: head_sha verification via git cat-file (requires subprocess).
- CA11: discovery_interval_s wall-time cadence (vs iteration count).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:26:06 -04:00

272 lines
11 KiB
Python

"""AWAITING_CI status poll — autonomous CI exit (Phase 1k++++ trial).
Closes the last operator-intervention gap for the Phase 2 trial.
Previously, workflows that transitioned to AWAITING_CI sat there
forever (only ``ci_polling_exhausted`` fired, leading to STUCK after
the 2h timeout). The RUNBOOK had a manual SQL workaround.
This module ships an autonomous tick:
1. SELECT workflows in AWAITING_CI + their latest implementer
attempt's ``head_sha_after`` (the SHA the implementer pushed).
2. For each: call ``get_ci_status(owner, repo, head_sha)`` — wraps
Forgejo's ``/commits/{sha}/status`` combined-status endpoint.
3. Decide the next event based on the combined ``state`` field:
- ``success`` → ``ci_green`` → REVIEWING
- ``failure`` / ``error`` → ``ci_red_retry_same_tier`` →
IMPLEMENTING (the scheduler will then enqueue a fresh
implementer attempt at the same tier; pickup_guard +
ci_poll_exhaustion catch infinite loops)
- ``pending`` / ``queued`` / ``in_progress`` → no-op (wait for
the next tick)
- None / fetch failure → no-op (transient; reconciliation also
re-checks PR state independently)
What this module DOES NOT do (yet):
- Per-gate CI summarization. ``ci_summarize.py`` exists but plugging
it in would require fetching every job log per gate — heavy. For
the trial we only need the overall state to advance the workflow.
The next implementer attempt's prefetch picks up the failing
gates via the existing ci_summary path.
- Escalation to ``ci_red_escalate``. Always uses retry_same_tier;
ESCALATING is reached via the regular attempts-per-tier exhaustion
path (pickup_guard / outcome mapper).
- Flake detection. Future ``ci_flake_retry`` integration ships when
the flake classifier lands.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
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
logger = logging.getLogger(__name__)
# get_ci_status(owner, repo, head_sha) → Forgejo combined-status
# dict ({"state": "...", "statuses": [...]}) or None on transient
# fetch failure.
GetCIStatusCallback = Callable[[str, str, str], dict | None]
# Map Forgejo combined-status state → state-machine event.
# None = no-op (wait for next tick).
#
# CA8: ``cancelled`` and ``stale`` are NOT genuine CI failures —
# ``cancelled`` is typically an operator hitting "cancel job" or CI
# system shutting down; ``stale`` is when a new push superseded the
# run on a different branch (common during force-pushes). Treating
# these as ci_red burns a ``pickup_count`` slot on a healthy PR,
# which combined with MAX_PICKUPS pushes the workflow to STUCK after
# a few cancels. Map them to None (wait for the next tick) so the
# poller picks up the eventual real status (or operator can re-run).
# ``timed_out`` IS a real failure — keep that as red.
_STATE_TO_EVENT: dict[str | None, str | None] = {
"success": "ci_green",
"failure": "ci_red_retry_same_tier",
"error": "ci_red_retry_same_tier",
"pending": None,
"queued": None,
"in_progress": None,
"warning": "ci_green", # advisory; treat as passed
"neutral": "ci_green",
"skipped": "ci_green",
"cancelled": None, # CA8 — operator/CI-system action, not a failure
"timed_out": "ci_red_retry_same_tier",
"action_required": None, # human intervention needed; wait
"stale": None, # CA8 — superseded run, wait for current to land
None: None,
}
@dataclass
class CIStatusPollReport:
"""Per-sweep summary."""
workflows_scanned: int = 0
workflows_advanced_green: int = 0
workflows_advanced_red: int = 0
workflows_waiting: int = 0
workflows_fetch_failed: int = 0
transitions: list[tuple[int, str, str]] = field(default_factory=list)
# Each entry: (workflow_id, ci_state, event_fired)
def run_ci_status_poll_tick(
engine: Engine, *,
owner: str, repo: str,
get_ci_status: GetCIStatusCallback,
) -> CIStatusPollReport:
"""One sweep: poll Forgejo CI status for every AWAITING_CI
workflow in this (owner, repo) + apply state-machine transitions.
"""
report = CIStatusPollReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
# Find AWAITING_CI workflows + the SHA whose CI to poll.
#
# CA6: both implementer AND conflict_resolver push commits.
# The previous query filtered ``a.role = 'implementer'`` so
# workflows that re-entered AWAITING_CI after CONFLICT_RESOLVING
# → IMPLEMENTING used a stale pre-conflict SHA. Union both roles
# and pick whichever attempt has the highest attempt_number.
#
# PD9: filter on ``a.outcome = 'resolved'`` (implementer) or
# ``a.outcome = 'resolved'`` (conflict_resolver) — a blocked
# attempt has ``head_sha_after`` equal to ``head_sha_before``
# (no push happened), so the previous query would treat the
# stale pre-blocked SHA as the "latest push" and false-green
# advance to REVIEWING for code that was never re-pushed.
rows = session.execute(
text(
"SELECT w.workflow_id, w.entity_number, "
" (SELECT a.head_sha_after FROM workflow_attempts a "
" WHERE a.workflow_id = w.workflow_id "
" AND a.role IN ('implementer', 'conflict_resolver') "
" AND a.status = 'complete' "
" AND a.outcome = 'resolved' "
" AND a.head_sha_after IS NOT NULL "
" ORDER BY a.attempt_number DESC LIMIT 1) AS head_sha "
" FROM workflows w "
" WHERE w.current_state = 'AWAITING_CI' "
" AND w.owner = :owner AND w.repo = :repo "
" AND w.kind = 'pr'"
),
{"owner": owner, "repo": repo},
).all()
report.workflows_scanned = len(rows)
for row in rows:
wf_id = row.workflow_id
head_sha = row.head_sha
if not head_sha:
logger.warning(
"ci_status_poll: workflow %s in AWAITING_CI without "
"a head_sha (no completed implementer attempt found); "
"skipping", wf_id,
)
report.workflows_waiting += 1
continue
try:
ci = get_ci_status(owner, repo, head_sha)
except Exception as exc: # noqa: BLE001 — transient
logger.warning(
"ci_status_poll: fetch failed for workflow %s "
"(head=%s): %s", wf_id, head_sha[:12], exc,
)
report.workflows_fetch_failed += 1
continue
if ci is None:
report.workflows_fetch_failed += 1
continue
ci_state = ci.get("state")
# Warn on unknown Forgejo states so a new state string
# surfaces in operator logs instead of being silently
# treated as "pending" (TE9: previously indistinguishable
# from real pending — operators got no signal).
if ci_state not in _STATE_TO_EVENT:
logger.warning(
"ci_status_poll: workflow %s reported unknown CI state "
"%r (head=%s); treating as wait — add to _STATE_TO_EVENT "
"if recurring",
wf_id, ci_state, (head_sha or "")[:12],
)
event = _STATE_TO_EVENT.get(ci_state)
if event is None:
# Pending / queued / in_progress / unknown → wait.
report.workflows_waiting += 1
continue
try:
new_state = apply_event("AWAITING_CI", event)
except (IllegalTransitionError, ValueError) as exc:
logger.warning(
"ci_status_poll: apply_event(AWAITING_CI, %s) "
"raised %s for workflow %s; skipping",
event, exc, wf_id,
)
continue
# Apply via direct UPDATE filtered on current_state so a
# concurrent reconciliation can't double-transition (TOCTOU
# defense, same pattern as promote.py).
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 = 'AWAITING_CI'"
),
{
"to_state": new_state, "now": now, "wf_id": wf_id,
},
)
if (result.rowcount or 0) == 0:
logger.info(
"ci_status_poll: workflow %s no longer in AWAITING_CI "
"(race lost); skipping event row",
wf_id,
)
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, :event_type, 'AWAITING_CI', "
" :to_state, :payload, 0, 0)"
),
{
"wf_id": wf_id, "ts": now,
# PD8: use the state-machine event name (ci_green /
# ci_red_retry_same_tier) directly so consumers
# joining on event_type don't see schema drift
# between sources.
"event_type": event,
"to_state": new_state,
"payload": json.dumps({
"reason": event,
"ci_state": ci_state,
"head_sha": head_sha,
"source": "ci_status_poll",
}),
},
)
if event == "ci_green":
report.workflows_advanced_green += 1
else:
report.workflows_advanced_red += 1
report.transitions.append((wf_id, ci_state, event))
if report.workflows_advanced_green or report.workflows_advanced_red:
logger.info(
"ci_status_poll: %d scanned (green=%d, red=%d, waiting=%d, "
"fetch_failed=%d)",
report.workflows_scanned, report.workflows_advanced_green,
report.workflows_advanced_red, report.workflows_waiting,
report.workflows_fetch_failed,
)
return report
__all__ = [
"CIStatusPollReport",
"GetCIStatusCallback",
"run_ci_status_poll_tick",
]