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>
229 lines
8.0 KiB
Python
229 lines
8.0 KiB
Python
"""Single deterministic git-push primitive for the controller worker.
|
|
|
|
Every worker-side push — implementer finalize, conflict-resolution
|
|
finalize, salvage — goes through :func:`worker_push` so the lease /
|
|
force / stale-detection semantics live in exactly one place.
|
|
|
|
Design (controller spec v3):
|
|
|
|
- The push is **leased against the SHA the worker started from**
|
|
(``expected_sha``), never a freshly-fetched remote tip. A concurrent
|
|
push that moved the branch is therefore *detected*, not silently
|
|
clobbered (the pre-fix MCP push leased against a just-fetched tip,
|
|
so the lease always passed — it destroyed an out-of-band commit).
|
|
- ``allow_rewrite=False`` (implementer): the worktree branch is a
|
|
fast-forward of ``expected_sha``; a plain push is used and *cannot*
|
|
clobber. A non-fast-forward rejection means the remote moved
|
|
→ ``stale_input``. A worktree HEAD that is NOT a descendant of
|
|
``expected_sha`` (the agent rewrote history) → ``diverged``.
|
|
- ``allow_rewrite=True`` (conflict-resolver rebase track): history was
|
|
rewritten on purpose; ``--force-with-lease`` pinned to
|
|
``expected_sha`` performs the rewrite while still refusing to
|
|
clobber a concurrently-moved branch.
|
|
- Transient infra failures (network / auth) are retried a bounded
|
|
number of times; ref-rejection failures are never retried — they
|
|
surface as ``stale_input`` so the caller re-prefetches.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
PushStatus = Literal["pushed", "stale_input", "diverged", "infra_error"]
|
|
|
|
# stderr fragments that mark a genuine *ref rejection* — the remote
|
|
# moved under us — as opposed to a transport/auth/policy failure.
|
|
# Matched case-insensitively. These are the PRECISE markers git prints
|
|
# for a non-fast-forward / stale-lease rejection; the generic summary
|
|
# line ("failed to push some refs") is deliberately NOT included — it
|
|
# accompanies every push failure (hook declines, permission denied,
|
|
# quota), and matching it would misclassify those as stale_input.
|
|
_REJECTION_MARKERS = (
|
|
"non-fast-forward",
|
|
"fetch first",
|
|
"stale info",
|
|
"[rejected]",
|
|
"cannot lock ref",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PushResult:
|
|
"""Outcome of a :func:`worker_push`.
|
|
|
|
- ``pushed`` — the push landed; ``remote_sha`` is the verified tip.
|
|
- ``stale_input`` — the remote moved off ``expected_sha`` during the
|
|
attempt (detected before OR at push time). The caller must
|
|
re-prefetch; nothing was overwritten.
|
|
- ``diverged`` — the worktree HEAD is not a fast-forward of
|
|
``expected_sha`` (a non-rewrite caller's agent rewrote history).
|
|
- ``infra_error`` — a transport / auth / verification failure that
|
|
bounded retries did not clear.
|
|
"""
|
|
|
|
status: PushStatus
|
|
remote_sha: str = ""
|
|
detail: str = ""
|
|
|
|
|
|
def _run(
|
|
worktree: Path,
|
|
*args: str,
|
|
timeout: float = 180.0,
|
|
env: dict | None = None,
|
|
) -> tuple[int, str, str]:
|
|
"""Run a git command in ``worktree``; return ``(rc, stdout, stderr)``.
|
|
Never raises — callers branch on the return code."""
|
|
try:
|
|
r = subprocess.run(
|
|
["git", "-C", str(worktree), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=env,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return 124, "", "git command timed out"
|
|
return r.returncode, r.stdout, r.stderr
|
|
|
|
|
|
def _is_rejection(stderr: str) -> bool:
|
|
low = (stderr or "").lower()
|
|
return any(m in low for m in _REJECTION_MARKERS)
|
|
|
|
|
|
def worker_push(
|
|
worktree: Path,
|
|
*,
|
|
remote: str = "origin",
|
|
head_ref: str,
|
|
expected_sha: str,
|
|
allow_rewrite: bool,
|
|
env: dict | None = None,
|
|
timeout: float = 180.0,
|
|
infra_retries: int = 2,
|
|
_sleep=time.sleep,
|
|
) -> PushResult:
|
|
"""Push the worktree HEAD to ``<remote>/<head_ref>``, leased against
|
|
``expected_sha`` (the SHA the worker validated + reset to at setup).
|
|
|
|
See the module docstring for the ``allow_rewrite`` contract. The
|
|
``_sleep`` parameter is an injection point for tests.
|
|
"""
|
|
worktree = Path(worktree)
|
|
tracking = f"{remote}/{head_ref}"
|
|
refspec_fetch = (
|
|
"fetch",
|
|
remote,
|
|
f"+{head_ref}:refs/remotes/{remote}/{head_ref}",
|
|
)
|
|
|
|
# 1. Observe the current remote tip. The explicit-refspec form only
|
|
# touches the remote-tracking ref, so it works even when
|
|
# <head_ref> is the worktree's checked-out branch.
|
|
rc, _, ferr = _run(
|
|
worktree, *refspec_fetch, timeout=min(timeout, 60.0), env=env
|
|
)
|
|
if rc != 0:
|
|
return PushResult(
|
|
"infra_error",
|
|
detail=f"pre-push fetch failed: {ferr.strip()[:300]}",
|
|
)
|
|
rc, out, _ = _run(worktree, "rev-parse", tracking, timeout=30.0, env=env)
|
|
if rc != 0:
|
|
return PushResult("infra_error", detail=f"could not resolve {tracking}")
|
|
remote_tip = out.strip()
|
|
|
|
# 2. Lease — pinned to the SHA the worker started from. A mismatch
|
|
# means a concurrent push moved the branch; bail without pushing.
|
|
if remote_tip != expected_sha:
|
|
return PushResult(
|
|
"stale_input",
|
|
detail=(
|
|
f"{tracking} moved {expected_sha[:12]} -> "
|
|
f"{remote_tip[:12]} during the attempt"
|
|
),
|
|
)
|
|
|
|
rc, out, _ = _run(worktree, "rev-parse", "HEAD", timeout=30.0, env=env)
|
|
if rc != 0:
|
|
return PushResult("infra_error", detail="could not resolve worktree HEAD")
|
|
local_head = out.strip()
|
|
|
|
# 3. Fast-forward contract for non-rewrite callers: the worktree
|
|
# HEAD must descend from expected_sha. If not, the agent rewrote
|
|
# history — that is the agent's error, not a stale remote.
|
|
if not allow_rewrite:
|
|
anc_rc, _, _ = _run(
|
|
worktree,
|
|
"merge-base",
|
|
"--is-ancestor",
|
|
expected_sha,
|
|
"HEAD",
|
|
timeout=30.0,
|
|
env=env,
|
|
)
|
|
if anc_rc == 1:
|
|
return PushResult(
|
|
"diverged",
|
|
detail=(
|
|
f"worktree HEAD {local_head[:12]} is not a fast-forward "
|
|
f"of base {expected_sha[:12]}"
|
|
),
|
|
)
|
|
if anc_rc not in (0, 1):
|
|
return PushResult(
|
|
"infra_error", detail="merge-base --is-ancestor errored"
|
|
)
|
|
|
|
push_cmd: list[str] = ["push", remote, f"HEAD:refs/heads/{head_ref}"]
|
|
if allow_rewrite:
|
|
push_cmd.append(
|
|
f"--force-with-lease=refs/heads/{head_ref}:{expected_sha}"
|
|
)
|
|
|
|
# 4. Push. Retry only transient infra failures; a ref rejection is
|
|
# a real concurrent push and surfaces immediately as stale_input.
|
|
last_detail = ""
|
|
pushed = False
|
|
for attempt in range(infra_retries + 1):
|
|
rc, _, perr = _run(worktree, *push_cmd, timeout=timeout, env=env)
|
|
if rc == 0:
|
|
pushed = True
|
|
break
|
|
if _is_rejection(perr):
|
|
return PushResult(
|
|
"stale_input",
|
|
detail=f"push rejected (remote moved): {perr.strip()[:300]}",
|
|
)
|
|
last_detail = f"push failed: {perr.strip()[:300]}"
|
|
if attempt < infra_retries:
|
|
_sleep(2.0 * (attempt + 1))
|
|
if not pushed:
|
|
return PushResult("infra_error", detail=last_detail)
|
|
|
|
# 5. Verify the push actually landed on the remote.
|
|
_run(worktree, *refspec_fetch, timeout=min(timeout, 60.0), env=env)
|
|
rc, out, _ = _run(worktree, "rev-parse", tracking, timeout=30.0, env=env)
|
|
landed = out.strip() if rc == 0 else ""
|
|
if landed != local_head:
|
|
return PushResult(
|
|
"infra_error",
|
|
detail=(
|
|
f"push reported success but {tracking}={landed[:12]} != "
|
|
f"HEAD={local_head[:12]}"
|
|
),
|
|
)
|
|
return PushResult(
|
|
"pushed",
|
|
remote_sha=local_head,
|
|
detail=f"pushed {local_head[:12]} to {tracking}",
|
|
)
|
|
|
|
|
|
__all__ = ["PushResult", "PushStatus", "worker_push"]
|