"""Deterministic post-agent finalize for implementer attempts. The implementer agent edits + commits in the worktree and finalizes — it no longer pushes. After the agent session ends the worker runs :func:`finalize_implementer_attempt`, which: 1. checks the worktree HEAD is a fast-forward of the PR base (a history-rewriting agent is a contract violation, not a stale remote); 2. runs the deterministic lint+typecheck gate, with a ruff auto-fix pass for the trivially-fixable lint subset; 3. pushes via the leased :func:`worker_push` primitive. The :class:`FinalizeResult` it returns — not the agent's emitted outcome — is authoritative. A crash between push and the runner's DB write self-heals: a re-run attempt's :func:`worker_push` sees the remote already moved off the input head_sha and returns ``stale_input`` → the master re-prefetches and rebuilds on the already-pushed work. """ from __future__ import annotations import logging import subprocess from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any from .gate import acquire_slot, lint_ruff, release_slot, run_gates from .git_push import worker_push logger = logging.getLogger(__name__) _GIT_IDENTITY = ( "-c", "user.email=cleveragents-auto@cleveragents.local", "-c", "user.name=cleveragents-auto", ) # Worker-authored finalize outcomes the runner must surface as a # WorkerError (status='failed' → the master re-enqueues). A 'complete' # attempt with no state-machine event is NOT re-dispatched by the # scheduler — so gate-failed MUST travel this path, not the completed # path, or a gate-failed implementer would stall the workflow. WORKER_ERROR_OUTCOMES = frozenset( {"stale-input", "worker-internal-error", "gate-failed"} ) @dataclass(frozen=True) class FinalizeResult: """Authoritative outcome of an implementer attempt. ``outcome`` is one of: ``resolved`` (gated + pushed), ``gate-failed`` (lint/typecheck failed on the agent's commits), ``blocked`` (the agent rewrote history), ``stale-input`` (the branch moved during the attempt), ``worker-internal-error`` (infra), or a passthrough of the agent's own outcome when the agent made no commits. """ outcome: str head_sha_after: str output_payload: dict[str, Any] detail: str = "" def _git(worktree: Path, *args: str, timeout: float = 120.0) -> tuple[int, str, str]: try: r = subprocess.run( ["git", "-C", str(worktree), *args], capture_output=True, text=True, timeout=timeout, ) except subprocess.TimeoutExpired: return 124, "", "git command timed out" return r.returncode, r.stdout, r.stderr def _git_ok(worktree: Path, *args: str, timeout: float = 120.0) -> str: rc, out, err = _git(worktree, *args, timeout=timeout) if rc != 0: raise RuntimeError(f"git {' '.join(args)} failed: {err.strip()}") return out def _commits(worktree: Path, base: str, head: str) -> list[str]: rc, out, _ = _git(worktree, "rev-list", "--reverse", f"{base}..{head}") return out.split() if rc == 0 else [] def _changed_files(worktree: Path, base: str, head: str) -> list[str]: rc, out, _ = _git(worktree, "diff", "--name-only", f"{base}..{head}") return [ln.strip() for ln in out.splitlines() if ln.strip()] if rc == 0 else [] def _with(payload: dict[str, Any], **overrides: Any) -> dict[str, Any]: return {**payload, **overrides} def _attempt_ruff_autofix( worktree: Path, files: list[str], slot_index: int ) -> bool: """Run ruff's deterministic auto-fix (safe lint fixes + format) on the agent's touched ``.py`` files, using the SAME pinned ruff the lint gate runs. Commits the result iff anything changed; returns True iff a fix commit was made. Note: ``ruff format`` may reformat lines the agent did not write within a touched file — expected and harmless (it makes the file CI-clean). The fix is scoped to the agent's touched files so it never silently rewrites unrelated pre-existing lint debt. """ ruff = lint_ruff(slot_index) if ruff is None: return False py_files = sorted({f for f in files if f.endswith(".py")}) if not py_files: return False # ``--fix`` applies only fixes ruff marks SAFE; ``--exit-zero`` so a # remaining non-auto-fixable violation does not abort the format. subprocess.run( [str(ruff), "check", "--fix", "--exit-zero", *py_files], cwd=str(worktree), capture_output=True, text=True, timeout=180, ) subprocess.run( [str(ruff), "format", *py_files], cwd=str(worktree), capture_output=True, text=True, timeout=180, ) rc, out, _ = _git(worktree, "status", "--porcelain") if rc != 0 or not out.strip(): return False # ruff changed nothing try: _git_ok(worktree, "add", "--", *py_files) _git_ok( worktree, *_GIT_IDENTITY, "commit", "-m", "chore: worker ruff auto-fix (pre-push lint gate)", ) except RuntimeError as exc: logger.warning("ruff auto-fix commit failed: %s", exc) return False return True def finalize_implementer_attempt( *, worktree: Path, input_payload: dict[str, Any], agent_output: dict[str, Any], tier: int | None, lost_lock_check: Callable[[], bool], _run_gates: Callable[..., Any] = run_gates, _worker_push: Callable[..., Any] = worker_push, _autofix: Callable[..., bool] = _attempt_ruff_autofix, ) -> FinalizeResult: """Gate + push an implementer attempt; return the authoritative outcome. See the module docstring for the contract.""" worktree = Path(worktree) base = input_payload.get("head_sha") head_ref = input_payload.get("head_ref") if not (isinstance(base, str) and base and isinstance(head_ref, str) and head_ref): return FinalizeResult( "worker-internal-error", base if isinstance(base, str) else "", _with(agent_output, commit_shas=[]), detail="input_payload missing head_sha / head_ref", ) rc, out, err = _git(worktree, "rev-parse", "HEAD") if rc != 0: return FinalizeResult( "worker-internal-error", base, _with(agent_output, commit_shas=[]), detail=f"could not read worktree HEAD: {err.strip()}", ) head = out.strip() # finalize gates + pushes a CLAIMED-RESOLVED attempt. Honor the # agent's own outcome — without gating or pushing — when there is # nothing to ship: either no commits, OR the agent explicitly # reported a non-resolved outcome (blocked / competence-failure / # noop / ...). Pushing stray commits an agent disowned would ship # partial work and silently override the agent's "I failed". The # commit list is zeroed so head is not seen to advance. agent_outcome = str(agent_output.get("outcome") or "noop") if head == base or agent_outcome != "resolved": return FinalizeResult( agent_outcome, base, _with(agent_output, commit_shas=[]), detail=( "implementer produced no commits" if head == base else f"agent outcome {agent_outcome!r} is not 'resolved' " "— not gating or pushing" ), ) # Divergence: a non-rewrite implementer's HEAD must descend from the # PR base. If not, the agent rewrote history — a contract violation, # not a stale remote. Re-dispatch lands on a fresh reset-to-base. anc_rc, _, _ = _git(worktree, "merge-base", "--is-ancestor", base, "HEAD") if anc_rc == 1: return FinalizeResult( "blocked", base, _with( agent_output, outcome="blocked", commit_shas=[], blockers=[ "implementer rewrote history; worktree HEAD is not a " "fast-forward of the PR base — re-dispatch on a clean base" ], ), detail="worktree HEAD diverged from the PR base", ) if anc_rc not in (0,): return FinalizeResult( "worker-internal-error", base, _with(agent_output, commit_shas=[]), detail="merge-base --is-ancestor errored", ) slot = acquire_slot() try: gate = _run_gates(worktree, slot_index=slot) # Trivial lint regressions (import order, formatting) are # deterministically auto-fixable — try that before spending a # whole re-dispatch on it, then re-gate. if gate.status == "fail" and gate.failing_gate == "lint": files = _changed_files(worktree, base, head) if _autofix(worktree, files, slot): rc, out, _ = _git(worktree, "rev-parse", "HEAD") head = out.strip() if rc == 0 else head gate = _run_gates(worktree, slot_index=slot) # Classify the (possibly re-run) gate result. ``error`` is an # infra failure — NOT the agent's fault and NOT counted against # the gate-retry budget — so it must be checked after the # auto-fix re-gate too, never collapsed into ``gate-failed``. if gate.status == "error": return FinalizeResult( "worker-internal-error", base, _with(agent_output, commit_shas=[]), detail=f"gate could not run ({gate.failing_gate}): " f"{gate.raw_tail[-300:]}", ) if gate.status == "fail": tail = (gate.raw_tail or "")[-1500:] return FinalizeResult( "gate-failed", base, _with( agent_output, outcome="gate-failed", commit_shas=[], # nothing pushed → head must not advance files_touched=_changed_files(worktree, base, head), blockers=[ f"local {gate.failing_gate} gate failed before " f"push:\n{tail}" ], ), detail=f"{gate.failing_gate} gate failed", ) # Gate green (possibly after the auto-fix). Push. if lost_lock_check(): return FinalizeResult( "worker-internal-error", base, _with(agent_output, commit_shas=[]), detail="lock lost before push", ) result = _worker_push( worktree, head_ref=head_ref, expected_sha=base, allow_rewrite=False, ) finally: release_slot(slot) if result.status == "pushed": return FinalizeResult( "resolved", result.remote_sha, _with( agent_output, outcome="resolved", commit_shas=_commits(worktree, base, result.remote_sha), files_touched=_changed_files(worktree, base, result.remote_sha), ), detail=result.detail, ) if result.status == "stale_input": return FinalizeResult( "stale-input", base, _with(agent_output, commit_shas=[]), detail=result.detail, ) if result.status == "diverged": return FinalizeResult( "blocked", base, _with( agent_output, outcome="blocked", commit_shas=[], blockers=["worktree HEAD diverged from the PR base at push"], ), detail=result.detail, ) # infra_error return FinalizeResult( "worker-internal-error", base, _with(agent_output, commit_shas=[]), detail=result.detail, ) __all__ = [ "FinalizeResult", "WORKER_ERROR_OUTCOMES", "finalize_implementer_attempt", ]