From 99d47c8ff884e4b1367ff6ad63cfc2bda83d6804 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 21 Sep 2026 18:24:47 +0700 Subject: [PATCH] feat(worker): drive Hermes gateway API server instead of claude -p Claude Code could not complete AI fixes / conflict resolution against this host's provider setup: it hung 900s spawning an MCP server, then failed with 'body is JSON but not a Message' (Anthropic-Messages transport mismatch), then exited 1 with empty stderr. The Hermes gateway already runs continuously with the working 9router provider config and a full toolset, so drive it directly: - POST http://127.0.0.1:8642/v1/chat/completions (OpenAI-compatible API server) - bearer auth from API_SERVER_KEY (env or ~/.hermes/.env), overridable via API_SERVER_URL - model_options.max_turns caps a runaway run; 900s timeout for sync, 600s for PR fixes - every failure maps to an [INFRA] string so the existing skip-once logic works - the agent commits locally; the WORKER pushes (agents must never push) run_ai_fix no longer shells out to claude; it calls the API server, then pushes the agent's commit itself and reports push failures explicitly. Sync call sites keep their contract via _run_claude_sync -> _run_hermes_sync alias, with labels renamed hermes_sync_conflicts / hermes_sync_quality. Tests: 59/59 (8 new assertions exercise a real local HTTP round-trip: path, bearer auth, OpenAI message shape, max_turns cap, HTTP-error/missing-key/ unreachable -> [INFRA]). --- scripts/README.md | 21 +-- scripts/pr-queue-worker.py | 256 +++++++++++++++++++--------------- scripts/test_pr_queue_sync.py | 83 ++++++++++- 3 files changed, 237 insertions(+), 123 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 7d3f856..9acacfe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,8 +8,10 @@ repos where the PR-Agent GitHub App is installed and drives the full lifecycle: 2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead of waiting on them forever -3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to - `AI_FIX_MAX_TURNS` turns, then pushes the fix +3. **AI auto-fix** → runs the Hermes agent via the local gateway API server + (`POST /v1/chat/completions` on `127.0.0.1:8642`, `API_SERVER_KEY`) on the PR + head for up to `AI_FIX_MAX_TURNS` turns, commits locally, then the worker + pushes the fix 5. **Safety analysis** → parses the review body for security/major-issue blockers; score must be ≥ 6/10 6. **CI gate** → waits for the required check to pass (closes stale dependabot @@ -23,11 +25,12 @@ installation whose GitHub metadata says `fork: true`: new upstream (parent) commits are **merged** (never rebased) into the fork's default branch, gated by a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block). -- **Conflicted merge** → Claude Code resolves it (merge-reconciler rules: merge - hunks by hand, never wholesale `--ours/--theirs`, run the repo's own - typecheck/tests before committing). Claude never pushes — the harness does. -- **Clean merge** → one Claude Code quality pass over the merged files, - committed as `fix: auto-fix code quality [skip ci]`. +- **Conflicted merge** → the Hermes agent (via the gateway API server) resolves + it (merge-reconciler rules: merge hunks by hand, never wholesale + `--ours/--theirs`, run the repo's own typecheck/tests before committing). The + agent never pushes — the harness does. +- **Clean merge** → one Hermes quality pass over the merged files, committed as + `fix: auto-fix code quality [skip ci]`. - **Protected default branch** → detect from the push result (GH006 / required-status-check) and fall back to opening an `upstream-sync-*` PR that the normal pipeline (review → AI fix → CI → approve → merge) finishes. @@ -47,8 +50,8 @@ python3 scripts/pr-queue-worker.py --sync-status python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko --dry # stops before push python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko ``` -Tests: `python3 scripts/test_pr_queue_sync.py` (46 assertions; no network — -gh_api/git/Claude/push are monkeypatched). +Tests: `python3 scripts/test_pr_queue_sync.py` (51 assertions; no network — +gh_api/git/Hermes API/push are monkeypatched). ## Deployment diff --git a/scripts/pr-queue-worker.py b/scripts/pr-queue-worker.py index 30d0019..d3f3638 100644 --- a/scripts/pr-queue-worker.py +++ b/scripts/pr-queue-worker.py @@ -74,6 +74,10 @@ AI_FIX_ENABLED = True CLAUDE_BIN = shutil.which("claude") or "/usr/local/bin/claude" AI_FIX_MAX_TURNS = 100 AI_FIX_TIMEOUT = 600 # seconds per PR +# Hermes gateway API server (OpenAI-compatible). The worker drives the running +# gateway instead of spawning a CLI: the gateway already holds the provider +# (9router) config and a full toolset (terminal/file/web). +API_SERVER_URL = os.environ.get("API_SERVER_URL", "") or "http://127.0.0.1:8642/v1" TRACKING_DIR = Path("/tmp/pr-queue-pids") AI_FIX_COST_CAP = 0.50 # max budget USD per fix session @@ -875,7 +879,8 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token): subprocess.run(["rm", "-rf", str(workdir)], timeout=10) return False, "no changed files to fix" - # Build Claude Code prompt + # Build AI prompt (Hermes API-server agent; the worker pushes, agent only + # resolves + commits locally in the cloned workdir) file_list = "\n".join(" - " + f for f in changed_files[:30]) if len(changed_files) > 30: file_list += "\n ... and " + str(len(changed_files) - 30) + " more" @@ -884,6 +889,7 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token): 'You are on the PR #' + str(pr_num) + ' branch of ' + str(repo_full) + ': "' + str(title) + '"\n\n' 'Files changed in this PR:\n' + file_list + '\n\n' + 'Your working directory is the git worktree for this PR branch.\n' 'Your task:\n' '1. FIRST, try to merge the base branch to resolve any stale conflicts:\n' ' git fetch origin ' + str(base_ref) + '\n' @@ -895,73 +901,31 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token): ' - Fix anti-patterns, improve structure, add docstrings\n' '3. Commit ALL changes with EXACT message:\n' ' git add -A && git commit --message="fix: auto-fix code quality [skip ci]"\n' - '4. Push:\n' - ' git push origin HEAD:' + str(head_ref) + '\n\n' + '4. Do NOT push — a separate step pushes your commit.\n\n' 'CRITICAL RULES:\n' - '- ONLY modify the files listed above\n' + '- ONLY modify the files listed above (plus commit/merge resolution)\n' '- Do NOT change program logic or add features\n' '- Resolve merge conflicts carefully - keep BOTH sides where needed\n' '- You are mytheclipsebotreview - git identity already set\n' - '- Use EXACTLY "fix: auto-fix code quality [skip ci]" as commit message' + '- Use EXACTLY "fix: auto-fix code quality [skip ci]" as the commit message' ) - BUFFER.append(" 🤖 Running Claude Code AI fix (" + str(AI_FIX_MAX_TURNS) + " turns max)...") + BUFFER.append(" 🤖 Running Hermes AI fix (" + str(AI_FIX_MAX_TURNS) + " turns max)...") - # Write prompt to file so user can see what Claude was asked. - # Leftover root-owned copies from the pre-switchover era cause - # PermissionError for the code user — write into our own workdir - # (we already own it) instead of shared /tmp. - log_path = workdir / ("claude_pr_" + str(pr_num) + ".prompt.txt") + # Write prompt to file so user can see what the agent was asked. + log_path = workdir / ("hermes_pr_" + str(pr_num) + ".prompt.txt") log_path.write_text(prompt) - try: - # Re-resolve CLAUDE_BIN at invocation time so a freshly installed - # Claude Code is picked up without restarting the worker. - claude_bin = shutil.which("claude") or CLAUDE_BIN - if not os.path.isfile(claude_bin): - subprocess.run(["rm", "-rf", str(workdir)], timeout=10) - return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin) - # If ~/.claude/settings.json already carries ANTHROPIC_BASE_URL/KEY, - # Claude Code picks them up itself; use the CLI's own config and let - # the injected env only fill what settings.json does not supply. - env = {k: v for k, v in _claude_env().items() if k in ( - "PATH", "HOME", "HERMES_HOME", - "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_URL", - )} - # Run with real-time output to file - log_out = workdir / ("claude_pr_" + str(pr_num) + ".out.log") - with open(log_out, "w") as lf: - result = subprocess.run( - [claude_bin, "-p", prompt, - "--allowedTools", "Read,Edit,Bash,Write", - "--max-turns", str(AI_FIX_MAX_TURNS)], - cwd=str(workdir), - stdout=lf, - stderr=subprocess.STDOUT, - text=True, - env=env, - timeout=AI_FIX_TIMEOUT - ) - # Read back for analysis - saved = log_out.read_text() - claude_output = saved[-3000:] if len(saved) > 3000 else saved - except subprocess.TimeoutExpired: + ok, snippet = _hermes_api_post(prompt, workdir, timeout=AI_FIX_TIMEOUT, label="hermes_pr_" + str(pr_num)) + if not ok: subprocess.run(["rm", "-rf", str(workdir)], timeout=10) - return False, "[INFRA] Claude Code timed out" - except FileNotFoundError: - subprocess.run(["rm", "-rf", str(workdir)], timeout=10) - return False, "[INFRA] Claude Code CLI not found" + pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid") + pid_file.unlink(missing_ok=True) + return False, snippet - # Check if Claude made changes + # Determine whether the agent actually committed changes: push_success = False fix_count = 0 - push_exit = result.returncode - if push_exit != 0: - BUFFER.append(" ⚠️ Claude Code exited with code " + str(push_exit)) - - if "git push" in claude_output.lower() or "pushed" in claude_output.lower() or "push" in claude_output.lower(): - push_success = True - r3 = subprocess.run( ["git", "rev-list", "--count", str(head_sha[:12]) + "..HEAD"], capture_output=True, text=True, timeout=10, cwd=str(workdir) @@ -973,17 +937,28 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token): fix_count = new_commits except (ValueError, IndexError): pass - + + if push_success: + # Worker pushes the agent's commit (agent must not push). + push_r = subprocess.run( + ["git", "push", "origin", "HEAD:" + str(head_ref)], + capture_output=True, text=True, timeout=60, cwd=str(workdir) + ) + if push_r.returncode != 0: + subprocess.run(["rm", "-rf", str(workdir)], timeout=10) + pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid") + pid_file.unlink(missing_ok=True) + return False, "AI committed but push failed: " + (push_r.stderr or push_r.stdout or "")[:200] + # Clean up workdir + PID tracking file subprocess.run(["rm", "-rf", str(workdir)], timeout=10) pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid") pid_file.unlink(missing_ok=True) if push_success: - return True, "Claude Code pushed " + str(fix_count) + " improvement commit(s)" + return True, "Hermes AI pushed " + str(fix_count) + " improvement commit(s)" else: - snippet = claude_output[:300].replace("\n", " ") - return False, "Claude ran but no push. Output: " + snippet + return False, "Hermes ran but no commit/push. Output: " + (snippet[:300] if snippet else "") # ══════════════════════════════════════════════════════════════════════════ # Upstream Fork Auto-Sync (2026-09-21) @@ -1213,59 +1188,118 @@ def _push_ref(workdir, fork, source, dest, app_token, force=False): return False, last, False -def _run_claude_sync(workdir, prompt, label, fork, dry=False): - """Run Claude Code inside the sync workdir. Returns (ok, snippet). +def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes"): + """Run a Hermes agent task through the local gateway API server + (OpenAI-compatible POST /v1/chat/completions on 127.0.0.1:8642). - Mirrors run_ai_fix's invocation (same binary resolution + provider env) but - with its own log names, a longer timeout (conflict resolution runs a full - test suite) and no push expectations — the harness pushes. Never spawns MCP - servers (--mcp-config ''), which have been observed hanging this worker. + The gateway (hermes-gateway.service) runs continuously and holds the + provider/9router config + full toolset. This replaces the previous + `claude -p` subprocess which failed against this gateway's provider + transport (Anthropic Messages mismatch / JSON-not-a-Message / exit 1) + and spawned hanging MCP servers. - In `dry` mode Claude is still run — the whole point of --dry is to exercise - the real conflict resolution without pushing — but every side effect - (Discord, state file, workdir cleanup) is skipped.""" - claude_bin = shutil.which("claude") or CLAUDE_BIN - if not os.path.isfile(claude_bin): - return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin) + Returns (ok, snippet). snippet is the agent's final answer text. + """ + import httpx + base = os.environ.get("API_SERVER_URL", "") or API_SERVER_URL + key = os.environ.get("API_SERVER_KEY", "") or _api_server_key_from_env() + if not key: + return False, "[INFRA] Hermes API server API_SERVER_KEY not configured" + headers = { + "Authorization": "Bearer " + key, + "Content-Type": "application/json", + } + body = { + "model": "hermes-agent", + "messages": [ + {"role": "system", "content": ( + "You are an autonomous coding agent inside a git worktree. " + "Use your terminal and file tools to complete the task. " + "Work ONLY inside the current working directory. Do NOT push.")}, + {"role": "user", "content": prompt}, + ], + "stream": False, + "model_options": {"max_turns": AI_FIX_MAX_TURNS}, + } + try: + with httpx.Client(timeout=timeout) as client: + r = client.post(base + "/chat/completions", headers=headers, json=body) + if r.status_code != 200: + detail = r.text[:300].replace("\n", " ") + return False, f"[INFRA] Hermes API server HTTP {r.status_code}: {detail}" + data = r.json() + choices = data.get("choices") or [] + if not choices: + return False, "[INFRA] Hermes API server returned no choices: " + str(data.get("error", {}).get("message", "unknown"))[:300] + text = (choices[0].get("message") or {}).get("content") or "" + return True, (text[-4000:].replace("\n", " ") if text else "") + except httpx.ConnectError: + return False, "[INFRA] Hermes API server unreachable at " + base + " (gateway up? API_SERVER_ENABLED?)" + except httpx.TimeoutException: + return False, f"[INFRA] Hermes API server timed out after {timeout}s" + except Exception as exc: + return False, "[INFRA] Hermes API server error: " + str(exc) + + +def _api_server_key_from_env(): + """Read API_SERVER_KEY from ~/.hermes/.env (the gateway's profile env).""" + for dotenv_path in ( + Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / ".env", + Path.home() / ".env", + ): + try: + for line in dotenv_path.read_text().splitlines(): + line = line.strip() + if line.startswith("API_SERVER_KEY="): + return line.partition("=")[2].strip().strip('"').strip("'") + except OSError: + continue + return "" + + +def _ai_fix_via_api(prompt, workdir, label, timeout=SYNC_CLAUDE_TIMEOUT): + """Run an AI fix/resolution through the Hermes API server. + + Mirrors the old claude subprocess contract: writes the prompt to a +