Phase 1: webhook handler + systemd unit + flock in dev-task

This commit is contained in:
danny8632
2026-05-12 07:06:58 +00:00
parent a296b87065
commit b3ab9639af
3 changed files with 153 additions and 0 deletions

View File

@@ -18,6 +18,15 @@ REPO="${1:-}"
ISSUE="${2:-}"
[[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 <owner>/<repo> <issue-id>" >&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}"

View File

@@ -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

128
webhook/handler.py Normal file
View File

@@ -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()