2f1be34d12
Closes the four open items in `docs/development/auto-agents-tier-2-3-plan.md` § "Revised remaining scope (2026-05-08)" plus three rounds of fresh-eyes critique fold-in (rounds 3, 5, and post-round-5 polish). Highlights: - New continuous invariant verifiers on a shared `_verify_common.py` substrate: `verify_review_invariant.py` (R1: approval-without-CI) and `verify_implementer_invariant.py` (I1: head-commit fails commit-lint, I2: PR description missing Epic reference). Strictly additive cron-job- shaped scripts that open idempotent `auto/invariant-violation` issues; safe to run every 15 minutes in production. - New `implementer-helpers` skill at `.opencode/skills/implementer-helpers/SKILL.md` + CLI at `tools/implementer_validate.py` (4 subcommands: validate-commit-message, validate-pr-compliance, validate-file-budget, validate-changelog). Mirrors the reviewer side; `tools/_commit_lint.py` is shared so a future change to commit policy updates one place. - `auto-agents.md` watchdog gate: `DISPATCHERS_RUNNING=1` puts the primary orchestrator into watchdog-only mode. Heartbeat resolution + age computation factored into `tools/_watchdog_helpers.py` + the CLI `tools/watchdog_check.py` so the agent only needs `python3 tools/watchdog_check.py *` and `sleep *` bash permissions. The reader honours the env-var override first, then falls back to a freshest-mtime scan across `/var/run` / `$XDG_RUNTIME_DIR` / `/tmp` (deliberately diverging from the dispatcher's first-existing fallback to guard against stale heartbeats from previous root-owned sessions masking healthy user-mode heartbeats). - `_opencode_worker.py` audit: structured `error_kind` classification at every transport-error / timeout return site, plumbed through `_dispatch_runtime.py` into the cycle-log; new `_request_read` retry helper (3 × 0.5s linear backoff, transport-only) wrapping every idempotent read in a worker session so a single transient flap on a polling GET cannot trash a 10-minute worker session. - Static heredoc lint at `tests/auto_agents/test_prompt_heredoc_lint.py` glob-walks every agent prompt and skill recipe markdown, rejecting any heredoc bash recipe in a fenced code block (per `bash-commands.md` rule 2 — heredocs fail at OpenCode's permission-engine parse time). - `bash-commands.md` rule 2 + its fix-it advice both lead with apostrophe-safe `printf "%s" "<body>"` (double-quoted) form; single-quoted form documented as the fragile JSON-only fallback. - `CHANGELOG.md` carries the full multi-round narrative (round 3 CRITICAL/HIGH/MEDIUM/LOW fold-in, round 5 docstring drift + telemetry refactor + broader heredoc lint scope, post-round-5 doc-drift polish). Net delta: +911 passing tests / 3 skipped (was 825 / 3); ruff clean on every new file; pre-existing lint debt in `_dispatch_runtime.py`, `_opencode_worker.py`, `conftest.py`, `_commit_lint.py` unchanged and out of scope for this commit. Co-authored-by: Cursor <cursoragent@cursor.com>
415 lines
15 KiB
Python
415 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Continuous implementer-pipeline invariant verifier.
|
|
|
|
Audits open PRs against the deterministic invariants the implementer
|
|
pipeline owns and opens ``auto/invariant-violation`` issues on
|
|
breach. Strictly additive — does not write any other state, and the
|
|
issues it opens carry a verifier-specific marker so a future re-run
|
|
is idempotent.
|
|
|
|
The implementer pipeline (``tools/dispatch_implementer.py`` + the
|
|
``task-implementor`` agent + the ``implementer-helpers`` skill)
|
|
catches PR Compliance Checklist failures before the worker exits.
|
|
This verifier catches the residual class: PRs where the worker
|
|
bypassed every pre-check (or a human-authored PR landed without
|
|
following the checklist) and the dispatcher's PR Compliance
|
|
Checklist invariants quietly drifted. Scope:
|
|
|
|
- **I1: Head commit fails commit-lint**. The PR's head commit
|
|
message fails ``_commit_lint.lint_commit_message`` with
|
|
``is_head=True`` (conventional-commit subject prefix or missing
|
|
``ISSUES CLOSED: #N`` footer). Bot-author exemption applies — a
|
|
merge-bot commit on the head is not a violation.
|
|
|
|
- **I2: PR description missing Epic reference**. The PR body has
|
|
no ``Epic: #N`` / ``Parent: #N`` line (per the dispatcher's PR
|
|
Compliance Checklist item 6 + the ``implementer-helpers``
|
|
skill's ``validate-pr-compliance`` regex).
|
|
|
|
This verifier does NOT police claim-label staleness — that is the
|
|
merge driver's per-cycle ``sweep_expired_claims`` job. It does NOT
|
|
police file-budget / CHANGELOG drift either; those require pulling
|
|
the diff from a worktree, which is heavyweight for a 15-min cron
|
|
job and is already enforced by reviewer feedback + CI.
|
|
|
|
Usage::
|
|
|
|
python3 tools/verify_implementer_invariant.py
|
|
python3 tools/verify_implementer_invariant.py --mode observe
|
|
python3 tools/verify_implementer_invariant.py --format json
|
|
python3 tools/verify_implementer_invariant.py --dry-run
|
|
|
|
Exit codes:
|
|
|
|
- ``0`` — verifier ran, no violations detected.
|
|
- ``1`` — verifier ran, ≥ 1 violation detected.
|
|
- ``2`` — verifier could not run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_TOOLS_DIR = str(Path(__file__).resolve().parent)
|
|
if _TOOLS_DIR not in sys.path:
|
|
sys.path.insert(0, _TOOLS_DIR)
|
|
|
|
from _loader import load_sibling as _load_sibling # noqa: I001, E402 type: ignore[import-not-found]
|
|
|
|
_common = _load_sibling("_verify_common", "_verify_common.py")
|
|
_commit_lint = _load_sibling("_commit_lint", "_commit_lint.py")
|
|
_implementer_validate = _load_sibling(
|
|
"implementer_validate", "implementer_validate.py"
|
|
)
|
|
|
|
|
|
logger = logging.getLogger("verify_implementer_invariant")
|
|
|
|
|
|
# Verifier-specific marker so issues do not collide with other
|
|
# auto-agents verifiers.
|
|
ISSUE_BODY_MARKER = "<!-- auto-agents: implementer-invariant-violation -->"
|
|
|
|
|
|
def _configure_logging() -> None:
|
|
env_level = os.environ.get("VERIFY_INVARIANT_LOG_LEVEL")
|
|
level = (
|
|
getattr(logging, env_level.upper(), logging.INFO)
|
|
if env_level
|
|
else logging.INFO
|
|
)
|
|
if not logging.getLogger().handlers:
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
stream=sys.stderr,
|
|
)
|
|
else:
|
|
logging.getLogger().setLevel(level)
|
|
|
|
|
|
# ─── Per-invariant helpers ────────────────────────────────────────────────
|
|
|
|
|
|
def fetch_head_commit(token: str, sha: str) -> dict[str, Any] | None:
|
|
"""Return the Forgejo commit object for ``sha``. ``None`` on any
|
|
HTTP error so the caller can skip the audit gracefully (the
|
|
worst case is a single missed audit, replayed next cron run)."""
|
|
res = _common.api(
|
|
"GET",
|
|
f"/repos/{_common.REPO_OWNER}/{_common.REPO_NAME}/git/commits/"
|
|
f"{sha}",
|
|
token,
|
|
)
|
|
if res["status"] != 200 or not isinstance(res["body"], dict):
|
|
return None
|
|
return res["body"]
|
|
|
|
|
|
def audit_commit_lint(
|
|
token: str,
|
|
pr: dict[str, Any],
|
|
*,
|
|
telemetry: dict[str, int] | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Apply ``_commit_lint.lint_commit_message`` to the PR's head
|
|
commit. Returns a violation dict when the head commit fails the
|
|
lint (and is not bot-exempt); ``None`` otherwise.
|
|
|
|
When the head commit cannot be fetched (Forgejo transient error)
|
|
AND ``telemetry`` is provided, ``telemetry['skipped_due_to_transient_error']``
|
|
is incremented so the run-level summary can distinguish "audited
|
|
OK" from "could not audit". The audit returns ``None`` either
|
|
way — the worst case is a single missed audit replayed on the
|
|
next cron tick.
|
|
"""
|
|
head_sha = (pr.get("head") or {}).get("sha") or ""
|
|
if not head_sha:
|
|
return None
|
|
commit = fetch_head_commit(token, head_sha)
|
|
if commit is None:
|
|
if telemetry is not None:
|
|
telemetry["skipped_due_to_transient_error"] = (
|
|
telemetry.get("skipped_due_to_transient_error", 0) + 1
|
|
)
|
|
return None
|
|
message = commit.get("message", "") or ""
|
|
committer = commit.get("committer") or {}
|
|
committer_email = committer.get("email", "") or ""
|
|
result = _commit_lint.lint_commit_message(
|
|
message, committer_email, is_head=True
|
|
)
|
|
if result.get("ok") or result.get("exempt_bot"):
|
|
return None
|
|
return {
|
|
"kind": "commit-lint",
|
|
"pr_number": int(pr.get("number") or 0),
|
|
"head_sha": head_sha,
|
|
"committer_email": committer_email,
|
|
"subject": (message.splitlines() or [""])[0],
|
|
"violations": result.get("violations", []),
|
|
}
|
|
|
|
|
|
def audit_pr_compliance(pr: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Apply the same Epic-reference regex
|
|
:data:`implementer_validate.EPIC_REFERENCE_RE` uses to the PR
|
|
body. Returns a violation dict when no Epic reference is present;
|
|
``None`` otherwise. The two modules import the same compiled
|
|
regex object so the dispatcher's draft-time helper and the
|
|
continuous verifier can never disagree.
|
|
"""
|
|
body = pr.get("body", "") or ""
|
|
if _implementer_validate.EPIC_REFERENCE_RE.search(body):
|
|
return None
|
|
return {
|
|
"kind": "pr-compliance",
|
|
"pr_number": int(pr.get("number") or 0),
|
|
"head_sha": (pr.get("head") or {}).get("sha") or "",
|
|
"rule": "epic-reference",
|
|
"details": (
|
|
"PR description has no 'Epic: #<n>' or 'Parent: #<n>' "
|
|
"line on its own. Implementer-helpers' "
|
|
"validate-pr-compliance subcommand catches this at "
|
|
"draft-time; a violation here means the worker bypassed "
|
|
"the helper or a human PR landed without following the "
|
|
"PR Compliance Checklist."
|
|
),
|
|
}
|
|
|
|
|
|
def audit_one_pr(
|
|
token: str,
|
|
pr: dict[str, Any],
|
|
*,
|
|
telemetry: dict[str, int] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Audit one PR for every implementer invariant. Returns a list
|
|
(possibly empty) of violation dicts. Drafts are skipped.
|
|
|
|
Predicates are listed as ``(name, callable)`` tuples so adding a
|
|
third invariant is one row, not three lines of cut-and-paste.
|
|
Each predicate either returns ``None`` (clean) or a violation
|
|
dict. The ``telemetry`` dict is forwarded only to predicates
|
|
that make remote API calls (currently just ``commit-lint``);
|
|
pure-data predicates ignore it.
|
|
"""
|
|
if pr.get("draft", False):
|
|
return []
|
|
predicates: tuple[
|
|
tuple[str, Any], ...
|
|
] = (
|
|
("commit-lint", lambda: audit_commit_lint(
|
|
token, pr, telemetry=telemetry
|
|
)),
|
|
("pr-compliance", lambda: audit_pr_compliance(pr)),
|
|
)
|
|
out: list[dict[str, Any]] = []
|
|
for _name, predicate in predicates:
|
|
violation = predicate()
|
|
if violation is not None:
|
|
out.append(violation)
|
|
return out
|
|
|
|
|
|
def _violation_payload(violation: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
|
|
"""Build ``(title, summary, body_meta)`` for one violation."""
|
|
pr_number = violation["pr_number"]
|
|
if violation["kind"] == "commit-lint":
|
|
title = (
|
|
f"Implementer invariant violation on PR #{pr_number}: "
|
|
f"head commit fails lint"
|
|
)
|
|
rules = ", ".join(
|
|
v.get("rule", "") for v in violation["violations"]
|
|
)
|
|
summary = (
|
|
f"PR #{pr_number}'s head commit `{violation['head_sha']}` "
|
|
f"fails commit-lint rules: {rules}. The "
|
|
f"`implementer-helpers` skill's `validate-commit-message` "
|
|
f"subcommand catches this at draft-time; a violation here "
|
|
f"means the worker bypassed the helper or a human commit "
|
|
f"landed without following CONTRIBUTING.md.\n\n"
|
|
f"Subject: `{violation['subject']}`\n"
|
|
f"Committer: `{violation['committer_email']}`"
|
|
)
|
|
body_meta = {
|
|
"invariant": "implementer-commit-lint",
|
|
"pr_number": pr_number,
|
|
"head_sha": violation["head_sha"],
|
|
"subject": violation["subject"],
|
|
"committer_email": violation["committer_email"],
|
|
"violations": violation["violations"],
|
|
}
|
|
elif violation["kind"] == "pr-compliance":
|
|
title = (
|
|
f"Implementer invariant violation on PR #{pr_number}: "
|
|
f"missing Epic reference"
|
|
)
|
|
summary = violation["details"]
|
|
body_meta = {
|
|
"invariant": "implementer-pr-compliance",
|
|
"pr_number": pr_number,
|
|
"head_sha": violation["head_sha"],
|
|
"rule": violation["rule"],
|
|
}
|
|
else:
|
|
# Defensive: shouldn't happen unless a future invariant is
|
|
# added without updating this branch.
|
|
raise RuntimeError(f"unknown violation kind: {violation['kind']!r}")
|
|
return title, summary, body_meta
|
|
|
|
|
|
def _classify_action(result: dict[str, Any]) -> str:
|
|
"""Map :func:`_common.open_violation_issue`'s return dict to a
|
|
cycle-log action label. Mirrors
|
|
:func:`verify_review_invariant._classify_action` so both verifiers
|
|
surface dry-run, success, and failure paths consistently.
|
|
"""
|
|
if result.get("created"):
|
|
return "issue_opened"
|
|
if result.get("issue") is not None:
|
|
return "issue_already_open"
|
|
if result.get("dry_run"):
|
|
return "would_create_issue"
|
|
return "issue_creation_failed"
|
|
|
|
|
|
def run(token: str, *, mode: str, dry_run: bool) -> dict[str, Any]:
|
|
"""Top-level audit loop. Iterates open PRs, audits each, and (in
|
|
``alert`` mode) opens an issue per violation.
|
|
|
|
The returned summary always includes
|
|
``skipped_due_to_transient_error`` so the cron caller (and the
|
|
cycle-log emitter) can pivot on "no violations because everything
|
|
audited cleanly" vs "no violations because we couldn't audit
|
|
some PRs at all".
|
|
"""
|
|
audited = 0
|
|
telemetry: dict[str, int] = {"skipped_due_to_transient_error": 0}
|
|
violations: list[dict[str, Any]] = []
|
|
actions: list[dict[str, Any]] = []
|
|
for pr in _common.iterate_open_prs(token):
|
|
audited += 1
|
|
for v in audit_one_pr(token, pr, telemetry=telemetry):
|
|
violations.append(v)
|
|
title, summary, meta = _violation_payload(v)
|
|
# Identifier is keyed on (pr#, kind, head_sha) so a single
|
|
# PR with both a commit-lint AND a pr-compliance violation
|
|
# gets two distinct issues, but a re-run does not duplicate
|
|
# them.
|
|
identifier = (
|
|
f"pr#{v['pr_number']}@{v['head_sha']}#{v['kind']}"
|
|
)
|
|
if mode == "observe":
|
|
logger.warning(
|
|
"implementer invariant violation (observe-only): %s",
|
|
json.dumps(meta),
|
|
)
|
|
actions.append(
|
|
{"identifier": identifier, "action_taken": "observed"}
|
|
)
|
|
continue
|
|
result = _common.open_violation_issue(
|
|
token,
|
|
title=title,
|
|
body_meta=meta,
|
|
marker=ISSUE_BODY_MARKER,
|
|
identifier=identifier,
|
|
summary=summary,
|
|
dry_run=dry_run,
|
|
)
|
|
actions.append(
|
|
{
|
|
"identifier": identifier,
|
|
"action_taken": _classify_action(result),
|
|
"result": result,
|
|
}
|
|
)
|
|
return {
|
|
"audited_prs": audited,
|
|
"violations": violations,
|
|
"actions": actions,
|
|
"violation_count": len(violations),
|
|
"skipped_due_to_transient_error": telemetry[
|
|
"skipped_due_to_transient_error"
|
|
],
|
|
}
|
|
|
|
|
|
def _build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="verify_implementer_invariant",
|
|
description=(
|
|
"Continuous implementer-pipeline invariant verifier. "
|
|
"Audits open PRs against the dispatcher's PR Compliance "
|
|
"Checklist and opens auto/invariant-violation issues on "
|
|
"breach."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--mode",
|
|
choices=("alert", "observe"),
|
|
default="alert",
|
|
help=(
|
|
"alert (default) opens issues; observe logs to stderr "
|
|
"only."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=("text", "json"),
|
|
default="text",
|
|
help="Output format for the verifier summary.",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help=(
|
|
"In alert mode, refuse to actually create issues; print "
|
|
"what would have been created."
|
|
),
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
_configure_logging()
|
|
parser = _build_parser()
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
token = _common.load_token()
|
|
except RuntimeError as exc:
|
|
logger.error("could not load Forgejo token: %s", exc)
|
|
return 2
|
|
try:
|
|
summary = run(token, mode=args.mode, dry_run=args.dry_run)
|
|
except RuntimeError as exc:
|
|
# ``_common.api`` raises RuntimeError after exhausting its retry
|
|
# budget on transport-level failures. Translate that to exit
|
|
# code 2 ("verifier could not run") per the contract above.
|
|
logger.error("Forgejo API error during audit: %s", exc)
|
|
return 2
|
|
if args.format == "json":
|
|
print(json.dumps(summary, indent=2))
|
|
else:
|
|
print(
|
|
f"audited={summary['audited_prs']} "
|
|
f"violations={summary['violation_count']} "
|
|
f"skipped_transient="
|
|
f"{summary.get('skipped_due_to_transient_error', 0)} "
|
|
f"mode={args.mode}{' (dry-run)' if args.dry_run else ''}"
|
|
)
|
|
for action in summary["actions"]:
|
|
print(f" - {action['identifier']}: {action['action_taken']}")
|
|
return 1 if summary["violation_count"] > 0 else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|