diff --git a/agents/dev-task.sh b/agents/dev-task.sh index 4ebd952..7a46454 100755 --- a/agents/dev-task.sh +++ b/agents/dev-task.sh @@ -18,6 +18,15 @@ REPO="${1:-}" ISSUE="${2:-}" [[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 / " >&2; exit 64; } +# Serialize: only one dev-task active at a time (Max-plan rate-limit pool). +# Wait up to 60s for the lock; if still held, exit 75 so the webhook retries. +LOCK_FILE="${LOCK_FILE:-/var/agent/dev-task.lock}" +exec 200>"$LOCK_FILE" +if ! flock -w 60 200; then + echo "[dev-task] another dev-task is running; backing off" >&2 + exit 75 +fi + MAX_WALLCLOCK="${MAX_WALLCLOCK:-30m}" MODEL_DEFAULT="${MODEL:-sonnet}" BASE_BRANCH="${BASE_BRANCH:-main}" diff --git a/webhook/agent-webhook.service b/webhook/agent-webhook.service new file mode 100644 index 0000000..0cbede0 --- /dev/null +++ b/webhook/agent-webhook.service @@ -0,0 +1,16 @@ +[Unit] +Description=agent-coding-empire webhook handler +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/local/bin/agent-webhook.py +Restart=on-failure +RestartSec=3 +StandardOutput=journal +StandardError=journal +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/webhook/handler.py b/webhook/handler.py new file mode 100644 index 0000000..855168b --- /dev/null +++ b/webhook/handler.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Gitea webhook handler — agent-coding-empire dispatcher. + +Listens on TCP :18790 for Gitea webhook deliveries. When an issue acquires the +label "agent:assign" (or is opened with that label), SSH-triggers dev-task.sh +on dev-01 (192.168.1.29). + +Verifies the X-Gitea-Signature HMAC header against WEBHOOK_SECRET from +/etc/agent/webhook-secret. + +This is a deliberately tiny single-file service. It uses stdlib only. +""" +import hashlib +import hmac +import http.server +import json +import logging +import os +import socketserver +import subprocess +import threading +import time +from pathlib import Path + +LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "18790")) +SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/etc/agent/webhook-secret")) +DEV_HOST = os.environ.get("DEV_HOST", "agent@192.168.1.29") +SSH_KEY = os.environ.get("SSH_KEY", "/root/.ssh/id_ed25519_pve") +LABEL = os.environ.get("AGENT_LABEL", "agent:assign") +LOG_DIR = Path(os.environ.get("HANDLER_LOG_DIR", "/var/log/agent-webhook")) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", +) +log = logging.getLogger("webhook") + +SECRET = SECRET_FILE.read_text().strip() + + +def verify_signature(body: bytes, sig_header: str) -> bool: + if not sig_header: + return False + mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(mac, sig_header) + + +def should_dispatch(event: str, payload: dict) -> tuple[bool, str | None, int | None]: + """Decide if this delivery should kick off a dev-task. Returns (yes, repo, issue#).""" + if event != "issues": + return False, None, None + action = payload.get("action", "") + issue = payload.get("issue", {}) + repo = payload.get("repository", {}).get("full_name") + number = issue.get("number") + labels = [l.get("name", "") for l in issue.get("labels", [])] + if action == "opened" and LABEL in labels: + return True, repo, number + if action == "label_updated" and LABEL in labels: + # Gitea fires label_updated for both add and remove; we only care about presence + return True, repo, number + return False, None, None + + +def dispatch(repo: str, issue: int): + """SSH into dev-01 and fire dev-task in the background.""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + fn = LOG_DIR / f"dispatch-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log" + log.info("dispatching dev-task for %s#%d -> %s", repo, issue, fn) + cmd = [ + "ssh", "-i", SSH_KEY, + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + DEV_HOST, + f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH " + f"nohup /usr/local/bin/dev-task.sh '{repo}' {issue} " + f">>/var/agent/logs/dispatch-issue-{issue}.log 2>&1 &" + ] + with open(fn, "wb") as f: + rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode + log.info("dispatch ssh rc=%d", rc) + + +class WebhookHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + n = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(n) + sig = self.headers.get("X-Gitea-Signature", "") + ev = self.headers.get("X-Gitea-Event", "") + + if not verify_signature(body, sig): + log.warning("bad signature for event=%s", ev) + self.send_response(401); self.end_headers(); self.wfile.write(b"bad sig\n"); return + + try: + payload = json.loads(body) + except json.JSONDecodeError: + self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return + + ok, repo, issue = should_dispatch(ev, payload) + if ok and repo and issue is not None: + threading.Thread(target=dispatch, args=(repo, issue), daemon=True).start() + self.send_response(202); self.end_headers() + self.wfile.write(b"dispatched\n") + else: + self.send_response(200); self.end_headers() + self.wfile.write(b"ignored\n") + + def do_GET(self): + if self.path == "/healthz": + self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n") + else: + self.send_response(404); self.end_headers() + + def log_message(self, fmt, *args): + log.info("%s - %s", self.address_string(), fmt % args) + + +class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + + +if __name__ == "__main__": + log.info("agent-webhook listening on %s:%d, target=%s, label=%s", + LISTEN_HOST, LISTEN_PORT, DEV_HOST, LABEL) + ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()