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]).
This commit is contained in:
asepharyana
2026-09-21 18:24:47 +07:00
parent 0653f6c508
commit 99d47c8ff8
3 changed files with 237 additions and 123 deletions
+145 -111
View File
@@ -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
<label>.prompt.txt in the workdir for audit, calls the API server, returns
(ok, snippet)."""
try:
(workdir / (label + ".prompt.txt")).write_text(prompt)
except OSError:
pass
return _hermes_api_post(prompt, workdir, timeout=timeout, label=label)
def _run_hermes_sync(workdir, prompt, label, fork, dry=False):
"""Run Hermes agent inside the sync workdir (conflict resolution / quality pass).
Replacement for _run_claude_sync. The API-server agent runs with the
gateway's own cwd, so to make it operate inside `workdir` we prepend the
workdir path to the prompt and instruct the agent to cd there first.
Returns (ok, snippet)."""
out_path = workdir / (label + ".out.log")
if not dry:
try:
(workdir / (label + ".prompt.txt")).write_text(prompt)
except OSError:
pass
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",
)}
# Claude Code can hang for many minutes inside this worker when it spawns an
# MCP server that stalls (observed 2026-09-21: `ouroboros mcp serve` under
# uvx hung with zero output for 15+ minutes → the 900s timeout fired).
# --mcp-config '' alone does NOT stop it — `claude -p` still starts MCP
# servers from settings.json / managed / plugins. --strict-mcp-config
# ignores ALL other MCP configuration and confines file tools to the
# workdir. These calls only read/edit files in a throwaway clone and run
# git — no MCP server is ever needed.
mcp_args = ["--mcp-config", "", "--strict-mcp-config"]
out_path = workdir / (label + ".out.log")
try:
with open(out_path, "w") as lf:
res = subprocess.run(
[claude_bin, "-p", prompt,
"--allowedTools", "Read,Edit,Bash,Write",
"--max-turns", str(AI_FIX_MAX_TURNS)] + mcp_args,
cwd=str(workdir), stdout=lf, stderr=subprocess.STDOUT,
text=True, env=env, timeout=SYNC_CLAUDE_TIMEOUT,
)
saved = out_path.read_text() if out_path.exists() else ""
except subprocess.TimeoutExpired:
return False, f"[INFRA] Claude Code timed out after {SYNC_CLAUDE_TIMEOUT}s"
except FileNotFoundError:
return False, "[INFRA] Claude Code CLI not found"
except OSError as exc:
return False, f"[INFRA] Claude Code could not run: {exc}"
snippet = saved[-400:].replace("\n", " ") if saved else ""
if res.returncode != 0:
return False, f"claude exited {res.returncode}: {snippet}"
return True, snippet
prompt_with_dir = (
"Your working directory is " + str(workdir) + ". Start by running:\n"
" cd " + str(workdir) + "\n"
"Then complete the task below.\n\n" + prompt
)
ok, snippet = _hermes_api_post(prompt_with_dir, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label=label)
if ok:
try:
out_path.write_text(label + " ok: " + snippet + "\n")
except OSError:
pass
return ok, snippet
# alias so existing call sites keep working
_run_claude_sync = _run_hermes_sync
def _sync_unmerged_files(workdir):
@@ -1503,11 +1537,11 @@ def sync_fork_repo(token, fork, parent, local_branch, upstream_branch, upstream_
"skip_reason": note, "notified": False})
save_sync_state(state)
return "conflict-failed", note
BUFFER.append(f" 🤖 resolving {len(conflicted)} conflict(s) with Claude Code "
BUFFER.append(f" 🤖 resolving {len(conflicted)} conflict(s) with Hermes "
f"(up to {SYNC_CLAUDE_TIMEOUT}s)...")
ok, snippet = _run_claude_sync(
workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted),
"claude_sync_conflicts", fork, dry)
"hermes_sync_conflicts", fork, dry)
if not ok:
_sync_git(["merge", "--abort"], workdir, 60)
note = f"conflict resolution failed — {snippet[:200]}"
@@ -1525,20 +1559,20 @@ def sync_fork_repo(token, fork, parent, local_branch, upstream_branch, upstream_
"skip_reason": note, "notified": False})
save_sync_state(state)
return "conflict-failed", note
resolution = f"Claude Code resolved {len(conflicted)} conflict(s)"
resolution = f"Hermes resolved {len(conflicted)} conflict(s)"
else:
resolution = "clean merge"
if cfg.get("ai_fix_after_merge", True):
diff = _sync_git(["diff", "--name-only", f"{pre_merge_sha}..HEAD"], workdir, 120)
merged_files = [f for f in (diff.stdout or "").split("\n") if f.strip()]
if merged_files:
BUFFER.append(f" 🤖 Claude Code quality pass on {len(merged_files)} merged file(s)...")
BUFFER.append(f" 🤖 Hermes quality pass on {len(merged_files)} merged file(s)...")
ok, snippet = _run_claude_sync(
workdir, _quality_prompt(fork, parent, upstream_branch, merged_files),
"claude_sync_quality", fork, dry)
"hermes_sync_quality", fork, dry)
if ok:
if _sync_commit_if_dirty(workdir, "fix: auto-fix code quality [skip ci]"):
resolution += " + Claude Code quality pass committed"
resolution += " + Hermes quality pass committed"
else:
resolution += " + quality pass made no changes"
else: