a103a31bbf
The implementer agent no longer pushes to git — the controller worker now owns the push: it gates the agent's commits on lint+typecheck and pushes via a single leased primitive. Closes two production defects: - Clobber: the pre-fix MCP --force-with-lease leased against a freshly-fetched tip, so the lease always passed — an in-flight implementer destroyed a commit pushed to the PR branch during its run (lost a hand-pushed skip_coverage fix on PR #46). - Gate-skip: the agent verified only the CI-flagged gate, so a fix for one gate shipped fresh violations in another (lint flapped pass->fail across CI runs 198->199). Step 1 — worker_push primitive: - New git_push.py: one leased push, pinned to the SHA the worker started from; classifies pushed / stale_input / diverged / infra_error; bounded infra-retry. - mcp_git_server.push gains expected_sha for a correctly-pinned lease. - finalize_conflict_resolution migrated onto worker_push. Step 2 — deterministic gate: - New gate.py: per-slot nox env-dirs (no venv races), manifest-hash staleness keying, lazy warm-up. - local_ci_gate.sh gains --envdir. Step 3 — worker-owned gated push: - New implementer_finalize.py: divergence pre-check -> lint+typecheck gate -> ruff auto-fix -> leased push. finalize's outcome is authoritative over the agent's emitted outcome. - agent_runner integrates finalize; salvage no longer pushes. - outcomes/tick: gate-failed + push-time stale-input caps, epoch-scoped; WorkerError carries an output_payload so the gate report reaches the next attempt's prompt; prefetch surfaces gate-failed attempts. - The 5 task-implementor prompts drop the agent push step. Reviewed across 4 adversarial rounds; full controller+MCP suite green (1379 passed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""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",
|
|
]
|