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:
+12
-9
@@ -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
|
2. **Toolchain pin guard** → closes dependabot PRs that bump pinned toolchain
|
||||||
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
majors (see `TOOLCHAIN_PINS` map — typescript/eslint/@tsparticles/…) instead
|
||||||
of waiting on them forever
|
of waiting on them forever
|
||||||
3. **AI auto-fix** → runs Claude Code (`-p`) on the PR head for up to
|
3. **AI auto-fix** → runs the Hermes agent via the local gateway API server
|
||||||
`AI_FIX_MAX_TURNS` turns, then pushes the fix
|
(`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
|
5. **Safety analysis** → parses the review body for security/major-issue
|
||||||
blockers; score must be ≥ 6/10
|
blockers; score must be ≥ 6/10
|
||||||
6. **CI gate** → waits for the required check to pass (closes stale dependabot
|
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
|
commits are **merged** (never rebased) into the fork's default branch, gated by
|
||||||
a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block).
|
a per-repo interval (default **1 hour**; `UPSTREAM_SYNC` config block).
|
||||||
|
|
||||||
- **Conflicted merge** → Claude Code resolves it (merge-reconciler rules: merge
|
- **Conflicted merge** → the Hermes agent (via the gateway API server) resolves
|
||||||
hunks by hand, never wholesale `--ours/--theirs`, run the repo's own
|
it (merge-reconciler rules: merge hunks by hand, never wholesale
|
||||||
typecheck/tests before committing). Claude never pushes — the harness does.
|
`--ours/--theirs`, run the repo's own typecheck/tests before committing). The
|
||||||
- **Clean merge** → one Claude Code quality pass over the merged files,
|
agent never pushes — the harness does.
|
||||||
committed as `fix: auto-fix code quality [skip ci]`.
|
- **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 /
|
- **Protected default branch** → detect from the push result (GH006 /
|
||||||
required-status-check) and fall back to opening an `upstream-sync-*` PR that
|
required-status-check) and fall back to opening an `upstream-sync-*` PR that
|
||||||
the normal pipeline (review → AI fix → CI → approve → merge) finishes.
|
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 --dry # stops before push
|
||||||
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
python3 scripts/pr-queue-worker.py --sync-only asepharyana/shiro-neko
|
||||||
```
|
```
|
||||||
Tests: `python3 scripts/test_pr_queue_sync.py` (46 assertions; no network —
|
Tests: `python3 scripts/test_pr_queue_sync.py` (51 assertions; no network —
|
||||||
gh_api/git/Claude/push are monkeypatched).
|
gh_api/git/Hermes API/push are monkeypatched).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
+143
-109
@@ -74,6 +74,10 @@ AI_FIX_ENABLED = True
|
|||||||
CLAUDE_BIN = shutil.which("claude") or "/usr/local/bin/claude"
|
CLAUDE_BIN = shutil.which("claude") or "/usr/local/bin/claude"
|
||||||
AI_FIX_MAX_TURNS = 100
|
AI_FIX_MAX_TURNS = 100
|
||||||
AI_FIX_TIMEOUT = 600 # seconds per PR
|
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")
|
TRACKING_DIR = Path("/tmp/pr-queue-pids")
|
||||||
AI_FIX_COST_CAP = 0.50 # max budget USD per fix session
|
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)
|
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
|
||||||
return False, "no changed files to fix"
|
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])
|
file_list = "\n".join(" - " + f for f in changed_files[:30])
|
||||||
if len(changed_files) > 30:
|
if len(changed_files) > 30:
|
||||||
file_list += "\n ... and " + str(len(changed_files) - 30) + " more"
|
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'
|
'You are on the PR #' + str(pr_num) + ' branch of ' + str(repo_full) + ': "' + str(title) + '"\n\n'
|
||||||
'Files changed in this PR:\n'
|
'Files changed in this PR:\n'
|
||||||
+ file_list + '\n\n'
|
+ file_list + '\n\n'
|
||||||
|
'Your working directory is the git worktree for this PR branch.\n'
|
||||||
'Your task:\n'
|
'Your task:\n'
|
||||||
'1. FIRST, try to merge the base branch to resolve any stale conflicts:\n'
|
'1. FIRST, try to merge the base branch to resolve any stale conflicts:\n'
|
||||||
' git fetch origin ' + str(base_ref) + '\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'
|
' - Fix anti-patterns, improve structure, add docstrings\n'
|
||||||
'3. Commit ALL changes with EXACT message:\n'
|
'3. Commit ALL changes with EXACT message:\n'
|
||||||
' git add -A && git commit --message="fix: auto-fix code quality [skip ci]"\n'
|
' git add -A && git commit --message="fix: auto-fix code quality [skip ci]"\n'
|
||||||
'4. Push:\n'
|
'4. Do NOT push — a separate step pushes your commit.\n\n'
|
||||||
' git push origin HEAD:' + str(head_ref) + '\n\n'
|
|
||||||
'CRITICAL RULES:\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'
|
'- Do NOT change program logic or add features\n'
|
||||||
'- Resolve merge conflicts carefully - keep BOTH sides where needed\n'
|
'- Resolve merge conflicts carefully - keep BOTH sides where needed\n'
|
||||||
'- You are mytheclipsebotreview - git identity already set\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.
|
# Write prompt to file so user can see what the agent was asked.
|
||||||
# Leftover root-owned copies from the pre-switchover era cause
|
log_path = workdir / ("hermes_pr_" + str(pr_num) + ".prompt.txt")
|
||||||
# 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")
|
|
||||||
log_path.write_text(prompt)
|
log_path.write_text(prompt)
|
||||||
|
|
||||||
try:
|
ok, snippet = _hermes_api_post(prompt, workdir, timeout=AI_FIX_TIMEOUT, label="hermes_pr_" + str(pr_num))
|
||||||
# Re-resolve CLAUDE_BIN at invocation time so a freshly installed
|
if not ok:
|
||||||
# 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)
|
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
|
||||||
return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin)
|
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
|
||||||
# If ~/.claude/settings.json already carries ANTHROPIC_BASE_URL/KEY,
|
pid_file.unlink(missing_ok=True)
|
||||||
# Claude Code picks them up itself; use the CLI's own config and let
|
return False, snippet
|
||||||
# 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:
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Check if Claude made changes
|
# Determine whether the agent actually committed changes:
|
||||||
push_success = False
|
push_success = False
|
||||||
fix_count = 0
|
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(
|
r3 = subprocess.run(
|
||||||
["git", "rev-list", "--count", str(head_sha[:12]) + "..HEAD"],
|
["git", "rev-list", "--count", str(head_sha[:12]) + "..HEAD"],
|
||||||
capture_output=True, text=True, timeout=10, cwd=str(workdir)
|
capture_output=True, text=True, timeout=10, cwd=str(workdir)
|
||||||
@@ -974,16 +938,27 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
|
|||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
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
|
# Clean up workdir + PID tracking file
|
||||||
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
|
subprocess.run(["rm", "-rf", str(workdir)], timeout=10)
|
||||||
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
|
pid_file = TRACKING_DIR / (str(repo_full).replace("/", "_") + "_" + str(pr_num) + ".pid")
|
||||||
pid_file.unlink(missing_ok=True)
|
pid_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
if push_success:
|
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:
|
else:
|
||||||
snippet = claude_output[:300].replace("\n", " ")
|
return False, "Hermes ran but no commit/push. Output: " + (snippet[:300] if snippet else "")
|
||||||
return False, "Claude ran but no push. Output: " + snippet
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
# Upstream Fork Auto-Sync (2026-09-21)
|
# 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
|
return False, last, False
|
||||||
|
|
||||||
|
|
||||||
def _run_claude_sync(workdir, prompt, label, fork, dry=False):
|
def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes"):
|
||||||
"""Run Claude Code inside the sync workdir. Returns (ok, snippet).
|
"""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
|
The gateway (hermes-gateway.service) runs continuously and holds the
|
||||||
with its own log names, a longer timeout (conflict resolution runs a full
|
provider/9router config + full toolset. This replaces the previous
|
||||||
test suite) and no push expectations — the harness pushes. Never spawns MCP
|
`claude -p` subprocess which failed against this gateway's provider
|
||||||
servers (--mcp-config ''), which have been observed hanging this worker.
|
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
|
Returns (ok, snippet). snippet is the agent's final answer text.
|
||||||
the real conflict resolution without pushing — but every side effect
|
"""
|
||||||
(Discord, state file, workdir cleanup) is skipped."""
|
import httpx
|
||||||
claude_bin = shutil.which("claude") or CLAUDE_BIN
|
base = os.environ.get("API_SERVER_URL", "") or API_SERVER_URL
|
||||||
if not os.path.isfile(claude_bin):
|
key = os.environ.get("API_SERVER_KEY", "") or _api_server_key_from_env()
|
||||||
return False, "[INFRA] Claude Code CLI not found at " + str(claude_bin)
|
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:
|
if not dry:
|
||||||
try:
|
try:
|
||||||
(workdir / (label + ".prompt.txt")).write_text(prompt)
|
(workdir / (label + ".prompt.txt")).write_text(prompt)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
env = {k: v for k, v in _claude_env().items() if k in (
|
prompt_with_dir = (
|
||||||
"PATH", "HOME", "HERMES_HOME",
|
"Your working directory is " + str(workdir) + ". Start by running:\n"
|
||||||
"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_URL",
|
" cd " + str(workdir) + "\n"
|
||||||
)}
|
"Then complete the task below.\n\n" + prompt
|
||||||
# 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 ""
|
ok, snippet = _hermes_api_post(prompt_with_dir, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label=label)
|
||||||
except subprocess.TimeoutExpired:
|
if ok:
|
||||||
return False, f"[INFRA] Claude Code timed out after {SYNC_CLAUDE_TIMEOUT}s"
|
try:
|
||||||
except FileNotFoundError:
|
out_path.write_text(label + " ok: " + snippet + "\n")
|
||||||
return False, "[INFRA] Claude Code CLI not found"
|
except OSError:
|
||||||
except OSError as exc:
|
pass
|
||||||
return False, f"[INFRA] Claude Code could not run: {exc}"
|
return ok, snippet
|
||||||
snippet = saved[-400:].replace("\n", " ") if saved else ""
|
|
||||||
if res.returncode != 0:
|
|
||||||
return False, f"claude exited {res.returncode}: {snippet}"
|
# alias so existing call sites keep working
|
||||||
return True, snippet
|
_run_claude_sync = _run_hermes_sync
|
||||||
|
|
||||||
|
|
||||||
def _sync_unmerged_files(workdir):
|
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})
|
"skip_reason": note, "notified": False})
|
||||||
save_sync_state(state)
|
save_sync_state(state)
|
||||||
return "conflict-failed", note
|
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)...")
|
f"(up to {SYNC_CLAUDE_TIMEOUT}s)...")
|
||||||
ok, snippet = _run_claude_sync(
|
ok, snippet = _run_claude_sync(
|
||||||
workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted),
|
workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted),
|
||||||
"claude_sync_conflicts", fork, dry)
|
"hermes_sync_conflicts", fork, dry)
|
||||||
if not ok:
|
if not ok:
|
||||||
_sync_git(["merge", "--abort"], workdir, 60)
|
_sync_git(["merge", "--abort"], workdir, 60)
|
||||||
note = f"conflict resolution failed — {snippet[:200]}"
|
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})
|
"skip_reason": note, "notified": False})
|
||||||
save_sync_state(state)
|
save_sync_state(state)
|
||||||
return "conflict-failed", note
|
return "conflict-failed", note
|
||||||
resolution = f"Claude Code resolved {len(conflicted)} conflict(s)"
|
resolution = f"Hermes resolved {len(conflicted)} conflict(s)"
|
||||||
else:
|
else:
|
||||||
resolution = "clean merge"
|
resolution = "clean merge"
|
||||||
if cfg.get("ai_fix_after_merge", True):
|
if cfg.get("ai_fix_after_merge", True):
|
||||||
diff = _sync_git(["diff", "--name-only", f"{pre_merge_sha}..HEAD"], workdir, 120)
|
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()]
|
merged_files = [f for f in (diff.stdout or "").split("\n") if f.strip()]
|
||||||
if merged_files:
|
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(
|
ok, snippet = _run_claude_sync(
|
||||||
workdir, _quality_prompt(fork, parent, upstream_branch, merged_files),
|
workdir, _quality_prompt(fork, parent, upstream_branch, merged_files),
|
||||||
"claude_sync_quality", fork, dry)
|
"hermes_sync_quality", fork, dry)
|
||||||
if ok:
|
if ok:
|
||||||
if _sync_commit_if_dirty(workdir, "fix: auto-fix code quality [skip ci]"):
|
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:
|
else:
|
||||||
resolution += " + quality pass made no changes"
|
resolution += " + quality pass made no changes"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def test_clean_merge_synced(tmpdir):
|
|||||||
check("pending_verify records merge sha", entry["pending_verify"]["sha"] == gitstate["head"], str(entry))
|
check("pending_verify records merge sha", entry["pending_verify"]["sha"] == gitstate["head"], str(entry))
|
||||||
check("pending_verify records pre_merge sha", entry["pending_verify"]["pre_merge_sha"].startswith("pre"), str(entry))
|
check("pending_verify records pre_merge sha", entry["pending_verify"]["pre_merge_sha"].startswith("pre"), str(entry))
|
||||||
check("last_merged_upstream_sha recorded", entry["last_merged_upstream_sha"] == "up1", str(entry))
|
check("last_merged_upstream_sha recorded", entry["last_merged_upstream_sha"] == "up1", str(entry))
|
||||||
check("clean merge gets a quality pass", claude_calls == ["claude_sync_quality"], str(claude_calls))
|
check("clean merge gets a quality pass", claude_calls == ["hermes_sync_quality"], str(claude_calls))
|
||||||
|
|
||||||
|
|
||||||
def test_dry_run_is_pure(tmpdir):
|
def test_dry_run_is_pure(tmpdir):
|
||||||
@@ -265,7 +265,7 @@ def test_conflict_resolved(tmpdir):
|
|||||||
W._push_ref = lambda *a, **k: (True, "pat push ok", False)
|
W._push_ref = lambda *a, **k: (True, "pat push ok", False)
|
||||||
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
check("status synced", res == "synced", f"{res} {detail}")
|
check("status synced", res == "synced", f"{res} {detail}")
|
||||||
check("conflict runner used", labels == ["claude_sync_conflicts"], str(labels))
|
check("conflict runner used", labels == ["hermes_sync_conflicts"], str(labels))
|
||||||
check("merge completed once", finished["n"] == 1, str(finished))
|
check("merge completed once", finished["n"] == 1, str(finished))
|
||||||
check("resolution noted in detail", "resolved 2 conflict" in detail, detail)
|
check("resolution noted in detail", "resolved 2 conflict" in detail, detail)
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ def test_conflict_failed_skips_and_dedupes(tmpdir):
|
|||||||
make_state_file(tmpdir)
|
make_state_file(tmpdir)
|
||||||
state = {}
|
state = {}
|
||||||
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
_install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
|
||||||
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Claude Code timed out")
|
W._run_claude_sync = lambda *a, **k: (False, "[INFRA] Hermes API server timed out")
|
||||||
W._push_ref = lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not push after failed resolution"))
|
W._push_ref = lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not push after failed resolution"))
|
||||||
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
res, detail = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26, state, W.sync_config("x"))
|
||||||
entry = state["f/x"]
|
entry = state["f/x"]
|
||||||
@@ -464,6 +464,82 @@ def test_sync_config_override():
|
|||||||
W.UPSTREAM_SYNC["repos"] = {}
|
W.UPSTREAM_SYNC["repos"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 14. Hermes API-server client ──────────────────────────────────────────
|
||||||
|
def test_hermes_api_client(tmpdir):
|
||||||
|
"""The worker's Hermes client must speak OpenAI chat-completions and map
|
||||||
|
failures to [INFRA] strings the skip logic understands. Uses a real local
|
||||||
|
HTTP server (no external network)."""
|
||||||
|
print("14. Hermes API-server client (real HTTP round-trip)")
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
seen["path"] = self.path
|
||||||
|
seen["auth"] = self.headers.get("Authorization", "")
|
||||||
|
seen["body"] = body
|
||||||
|
if body.get("messages", [{}])[-1].get("content") == "boom":
|
||||||
|
self.send_response(500)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"error":{"message":"upstream exploded"}}')
|
||||||
|
return
|
||||||
|
payload = json.dumps({
|
||||||
|
"choices": [{"message": {"role": "assistant", "content": "resolved ok"}}]
|
||||||
|
}).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
srv = http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
port = srv.server_address[1]
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
W.API_SERVER_URL = f"http://127.0.0.1:{port}/v1"
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
ok, text = W._hermes_api_post("resolve these conflicts", tmpdir)
|
||||||
|
check("client posts to /v1/chat/completions", seen.get("path") == "/v1/chat/completions", str(seen.get("path")))
|
||||||
|
check("client sends bearer auth", seen.get("auth") == "Bearer test-key-0123456789", str(seen.get("auth")))
|
||||||
|
check("client sends openai messages shape",
|
||||||
|
seen.get("body", {}).get("messages", [{}])[0].get("role") == "system", str(seen.get("body"))[:200])
|
||||||
|
check("client caps turns via model_options",
|
||||||
|
seen.get("body", {}).get("model_options", {}).get("max_turns") == W.AI_FIX_MAX_TURNS,
|
||||||
|
str(seen.get("body", {}).get("model_options")))
|
||||||
|
check("client returns agent text", ok and text == "resolved ok", f"{ok} {text!r}")
|
||||||
|
|
||||||
|
ok2, err2 = W._hermes_api_post("boom", tmpdir)
|
||||||
|
check("HTTP error mapped to [INFRA]", (not ok2) and err2.startswith("[INFRA]") and "500" in err2, err2)
|
||||||
|
|
||||||
|
# missing key → INFRA, never a silent success
|
||||||
|
W._api_server_key_from_env = lambda: ""
|
||||||
|
saved = W.os.environ.pop("API_SERVER_KEY", None)
|
||||||
|
try:
|
||||||
|
ok3, err3 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("missing key is [INFRA]", (not ok3) and "API_SERVER_KEY" in err3, err3)
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
W.os.environ["API_SERVER_KEY"] = saved
|
||||||
|
|
||||||
|
# unreachable port → INFRA with guidance
|
||||||
|
W._api_server_key_from_env = lambda: "test-key-0123456789"
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:9/v1"
|
||||||
|
ok4, err4 = W._hermes_api_post("x", tmpdir)
|
||||||
|
check("unreachable server is [INFRA]", (not ok4) and "unreachable" in err4, err4)
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
W.API_SERVER_URL = "http://127.0.0.1:8642/v1"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
test_upstream_status_parsing()
|
test_upstream_status_parsing()
|
||||||
@@ -480,6 +556,7 @@ def main():
|
|||||||
test_list_fork_repos()
|
test_list_fork_repos()
|
||||||
test_push_error_classification()
|
test_push_error_classification()
|
||||||
test_sync_config_override()
|
test_sync_config_override()
|
||||||
|
test_hermes_api_client(pathlib.Path(tmpdir))
|
||||||
print(f"\n{len(PASSED)} passed, {len(FAILED)} failed")
|
print(f"\n{len(PASSED)} passed, {len(FAILED)} failed")
|
||||||
if FAILED:
|
if FAILED:
|
||||||
print("failed: " + ", ".join(FAILED))
|
print("failed: " + ", ".join(FAILED))
|
||||||
|
|||||||
Reference in New Issue
Block a user