feat(worker): per-fork session header + 3600s sync timeout + merge salvage

- _hermes_api_post sends X-Hermes-Session-Id so each fork sync uses a
  fresh gateway transcript (was: derived session reused 200k-token history
  across upstream tips, making later runs pay growing context)
- SYNC_CLAUDE_TIMEOUT 2700→3600: a fresh session needs ~25min/120 calls
  to resolve 17 files + verify + commit; 2700s cut the HTTP call while the
  agent was still committing
- salvage-on-timeout: when the agent call ends early (timeout/transport)
  but the merge is already committed (MERGE_HEAD gone), finish+push it
  instead of aborting — verified live: 17 conflicts resolved by agent,
  push ok, fork 27 ahead / 0 behind
- _sync_finish_merge blocks committing files that still carry conflict
  markers (guard against git add of unresolved files)
- tests: 68/68 (session header, salvage completed merge, abort unfinished,
  marker guard; fake-leak hardening in _install_git_fake)
This commit is contained in:
asepharyana committed 2026-09-21 21:02:33 +07:00
1 parent 99d47c8ff8
commit 66b567a6bc
3 files changed
+291 -21

No files matched your search

+63 -21
View File
@@ -73,7 +73,7 @@ AI_FIX_ENABLED = True
# skips when the binary exists in PATH but not at the hardcoded location. # skips when the binary exists in PATH but not at the hardcoded location.
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 = 1800 # seconds per PR (agent turns + tool calls, not one inference)
# Hermes gateway API server (OpenAI-compatible). The worker drives the running # Hermes gateway API server (OpenAI-compatible). The worker drives the running
# gateway instead of spawning a CLI: the gateway already holds the provider # gateway instead of spawning a CLI: the gateway already holds the provider
# (9router) config and a full toolset (terminal/file/web). # (9router) config and a full toolset (terminal/file/web).
@@ -985,7 +985,10 @@ def run_ai_fix(repo_full, pr_num, title, head_sha, head_ref, base_ref, token):
SYNC_STATE_FILE = Path("/tmp/pr-queue-sync-state.json") SYNC_STATE_FILE = Path("/tmp/pr-queue-sync-state.json")
SYNC_TMP_BASE = Path("/tmp/pr-queue-sync-work") SYNC_TMP_BASE = Path("/tmp/pr-queue-sync-work")
SYNC_PR_PREFIX = "upstream-sync-" SYNC_PR_PREFIX = "upstream-sync-"
SYNC_CLAUDE_TIMEOUT = 900 # seconds: conflict resolution + verification run SYNC_CLAUDE_TIMEOUT = 3600 # seconds: conflict resolution + verification run.
# Observed 2026-09-21: a fresh session needs ~25 min (56+ API calls) to resolve
# 17 files + verify + commit. 2700s cut the HTTP call while the agent was still
# committing. 3600 leaves room; salvage still catches a genuinely hung call.
UPSTREAM_SYNC = { UPSTREAM_SYNC = {
"enabled": os.environ.get("PR_AGENT_UPSTREAM_SYNC", "1") != "0", "enabled": os.environ.get("PR_AGENT_UPSTREAM_SYNC", "1") != "0",
"interval_h": 1.0, # check upstream hourly (user decision 2026-09-21) "interval_h": 1.0, # check upstream hourly (user decision 2026-09-21)
@@ -1188,7 +1191,7 @@ def _push_ref(workdir, fork, source, dest, app_token, force=False):
return False, last, False return False, last, False
def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes"): def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes", session_id=None):
"""Run a Hermes agent task through the local gateway API server """Run a Hermes agent task through the local gateway API server
(OpenAI-compatible POST /v1/chat/completions on 127.0.0.1:8642). (OpenAI-compatible POST /v1/chat/completions on 127.0.0.1:8642).
@@ -1198,6 +1201,12 @@ def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes
transport (Anthropic Messages mismatch / JSON-not-a-Message / exit 1) transport (Anthropic Messages mismatch / JSON-not-a-Message / exit 1)
and spawned hanging MCP servers. and spawned hanging MCP servers.
`session_id` (optional) is sent as X-Hermes-Session-Id. When the gateway
sees that header it continues that session's transcript (per SHA == one
session: a retry of the same upstream tip resumes where the previous run
stopped, and a NEW upstream tip gets a fresh context instead of paying a
growing 200k-token history from older runs).
Returns (ok, snippet). snippet is the agent's final answer text. Returns (ok, snippet). snippet is the agent's final answer text.
""" """
import httpx import httpx
@@ -1209,6 +1218,8 @@ def _hermes_api_post(prompt, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label="hermes
"Authorization": "Bearer " + key, "Authorization": "Bearer " + key,
"Content-Type": "application/json", "Content-Type": "application/json",
} }
if session_id:
headers["X-Hermes-Session-Id"] = session_id
body = { body = {
"model": "hermes-agent", "model": "hermes-agent",
"messages": [ "messages": [
@@ -1289,7 +1300,8 @@ def _run_hermes_sync(workdir, prompt, label, fork, dry=False):
" cd " + str(workdir) + "\n" " cd " + str(workdir) + "\n"
"Then complete the task below.\n\n" + prompt "Then complete the task below.\n\n" + prompt
) )
ok, snippet = _hermes_api_post(prompt_with_dir, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label=label) ok, snippet = _hermes_api_post(prompt_with_dir, workdir, timeout=SYNC_CLAUDE_TIMEOUT, label=label,
session_id="sync_" + str(fork).replace("/", "_"))
if ok: if ok:
try: try:
out_path.write_text(label + " ok: " + snippet + "\n") out_path.write_text(label + " ok: " + snippet + "\n")
@@ -1307,11 +1319,28 @@ def _sync_unmerged_files(workdir):
return [f for f in (r.stdout or "").split("\n") if f.strip()] return [f for f in (r.stdout or "").split("\n") if f.strip()]
def _sync_has_conflict_markers(workdir):
"""Tracked files that still contain conflict markers.
A file the agent `git add`ed mid-resolution would pass the unmerged check
while still carrying `<<<<<<<` — never commit that.
"""
r = _sync_git(["grep", "-l", "-E", r"^(<<<<<<<|>>>>>>>|=======)$"],
workdir, 60)
# git grep exits 1 when there are no matches (that is the healthy case).
if r.returncode not in (0, 1):
return []
return [f for f in (r.stdout or "").split("\n") if f.strip()]
def _sync_finish_merge(workdir): def _sync_finish_merge(workdir):
"""Complete an in-progress merge once every conflict is resolved.""" """Complete an in-progress merge once every conflict is resolved."""
unmerged = _sync_unmerged_files(workdir) unmerged = _sync_unmerged_files(workdir)
if unmerged: if unmerged:
return False, f"{len(unmerged)} file(s) still unmerged: {', '.join(unmerged[:5])}" return False, f"{len(unmerged)} file(s) still unmerged: {', '.join(unmerged[:5])}"
marked = _sync_has_conflict_markers(workdir)
if marked:
return False, f"conflict markers left in: {', '.join(marked[:5])}"
_sync_git(["add", "-A"], workdir, 120) _sync_git(["add", "-A"], workdir, 120)
r = _sync_git(["commit", "--no-edit"], workdir, 120) r = _sync_git(["commit", "--no-edit"], workdir, 120)
if r.returncode != 0: if r.returncode != 0:
@@ -1543,23 +1572,36 @@ def sync_fork_repo(token, fork, parent, local_branch, upstream_branch, upstream_
workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted), workdir, _conflict_prompt(fork, parent, upstream_branch, local_branch, conflicted),
"hermes_sync_conflicts", fork, dry) "hermes_sync_conflicts", fork, dry)
if not ok: if not ok:
_sync_git(["merge", "--abort"], workdir, 60) # Salvage: a timeout/transport error does NOT mean the agent
note = f"conflict resolution failed — {snippet[:200]}" # failed. Resolving N conflicts takes many minutes (observed:
if not dry: # 17 files ≈ 16 min, 128 API calls), so the HTTP call can time
entry.update({"last_sync_ts": time.time(), "last_attempt_sha": upstream_sha, # out AFTER the agent finished resolving and committed. Only
"skip_reason": note, "notified": False}) # abort when the workdir still shows unresolved state.
save_sync_state(state) salvaged, why_salvage = _sync_finish_merge(workdir)
return "conflict-failed", note if salvaged:
done, why = _sync_finish_merge(workdir) BUFFER.append(" ♻️ agent call ended early but the merge is complete — salvaged")
if not done: resolution = f"Hermes resolved {len(conflicted)} conflict(s); " \
_sync_git(["merge", "--abort"], workdir, 60) f"agent call ended early ({snippet[:80]}) — merge salvaged"
note = f"conflict resolution incomplete — {why}" else:
if not dry: _sync_git(["merge", "--abort"], workdir, 60)
entry.update({"last_sync_ts": time.time(), "last_attempt_sha": upstream_sha, note = f"conflict resolution failed — {snippet[:200]}"
"skip_reason": note, "notified": False}) if not dry:
save_sync_state(state) entry.update({"last_sync_ts": time.time(), "last_attempt_sha": upstream_sha,
return "conflict-failed", note "skip_reason": note, "notified": False})
resolution = f"Hermes resolved {len(conflicted)} conflict(s)" save_sync_state(state)
return "conflict-failed", note
done, why = True, "salvaged"
else:
done, why = _sync_finish_merge(workdir)
if not done:
_sync_git(["merge", "--abort"], workdir, 60)
note = f"conflict resolution incomplete — {why}"
if not dry:
entry.update({"last_sync_ts": time.time(), "last_attempt_sha": upstream_sha,
"skip_reason": note, "notified": False})
save_sync_state(state)
return "conflict-failed", note
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):
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Standalone smoke test for the Hermes gateway API server adapter.
Starts APIServerAdapter on a private port (default 8643) using the LIVE hermes
install, then drives a real agent turn through POST /v1/chat/completions —
proving the whole path the pr-queue-worker depends on:
bearer auth -> route -> AIAgent -> toolset (terminal/file) -> response
No gateway restart needed and nothing in the running gateway is touched.
"""
import argparse
import asyncio
import json
import os
import sys
import time
INSTALL = os.environ.get("HERMES_INSTALL", "/home/code/.hermes/hermes-agent")
sys.path.insert(0, INSTALL)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8643)
ap.add_argument("--key", default="standalone-smoke-key-0123456789abcdef")
ap.add_argument("--prompt", default=None)
ap.add_argument("--timeout", type=float, default=300.0)
ap.add_argument("--keep-alive", action="store_true", help="serve until Ctrl-C")
args = ap.parse_args()
os.environ["API_SERVER_KEY"] = args.key
os.environ["API_SERVER_HOST"] = "127.0.0.1"
os.environ["API_SERVER_PORT"] = str(args.port)
from gateway.config import PlatformConfig, Platform
from gateway.platforms.api_server import APIServerAdapter
cfg = PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "port": args.port, "key": args.key})
adapter = APIServerAdapter(cfg)
print(f"[smoke] starting API server adapter on 127.0.0.1:{args.port}", flush=True)
async def run():
ok = await adapter.connect()
print(f"[smoke] connect() -> {ok}", flush=True)
if not ok:
err = getattr(adapter, "_fatal_error", None) or getattr(adapter, "fatal_error", None)
print(f"[smoke] fatal: {err}", flush=True)
return 1
if args.keep_alive:
print("[smoke] serving — Ctrl-C to stop", flush=True)
while True:
await asyncio.sleep(3600)
await asyncio.sleep(0.5)
import httpx
prompt = args.prompt or (
"Use your terminal tool to run exactly: echo HERMES_API_OK && pwd\n"
"Then reply with the raw output only."
)
body = {
"model": "hermes-agent",
"messages": [{"role": "user", "content": prompt}],
"stream": False,
}
url = f"http://127.0.0.1:{args.port}/v1/chat/completions"
t0 = time.time()
try:
async with httpx.AsyncClient(timeout=args.timeout) as client:
r = await client.post(url, headers={"Authorization": f"Bearer {args.key}"}, json=body)
dt = time.time() - t0
print(f"[smoke] HTTP {r.status_code} in {dt:.1f}s", flush=True)
print("[smoke] body:", r.text[:1500], flush=True)
if r.status_code != 200:
return 1
data = r.json()
text = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
print(f"[smoke] agent text: {text[:400]!r}", flush=True)
if "HERMES_API_OK" not in text:
print("[smoke] FAIL: terminal tool output not present in the answer", flush=True)
return 1
print("[smoke] PASS: agent ran a real terminal tool through the API server", flush=True)
return 0
except Exception as exc:
print(f"[smoke] request failed: {type(exc).__name__}: {exc}", flush=True)
return 1
try:
return asyncio.run(run())
finally:
try:
asyncio.run(adapter.disconnect())
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())
+130
View File
@@ -29,6 +29,7 @@ def load_worker():
W = load_worker() W = load_worker()
_real_sync_finish_merge = W._sync_finish_merge
PASSED = [] PASSED = []
FAILED = [] FAILED = []
@@ -172,6 +173,13 @@ def _install_git_fake(merge_code=0, unmerged=None):
W._sync_git = fake_git W._sync_git = fake_git
W._sync_unmerged_files = lambda workdir: list(unmerged or []) W._sync_unmerged_files = lambda workdir: list(unmerged or [])
# Reset the marker guard too: tests that stub it out must not leak into the
# next test (a leftover stub silently disabled the commit guard).
W._sync_has_conflict_markers = lambda workdir: []
# Reset _sync_finish_merge to the real implementation. Test 4 stubs it with
# a fake that always succeeds — a leaked copy turns a FAILED resolution
# (test 5, 15b) into a bogus salvage and hides the skip/abort behavior.
W._sync_finish_merge = _real_sync_finish_merge
return state return state
@@ -256,6 +264,10 @@ def test_conflict_resolved(tmpdir):
W._run_claude_sync = fake_claude W._run_claude_sync = fake_claude
finished = {"n": 0} finished = {"n": 0}
# _install_git_fake defaults unmerged=[]; test 4's fake_claude flips it to []
# mid-run — reset after the test so the leak does not poison test 5.
# (test 5 deliberately runs a FAILED resolution and must see the merge
# still in conflict, not a bogus salvage.)
def fake_finish(workdir): def fake_finish(workdir):
finished["n"] += 1 finished["n"] += 1
@@ -268,6 +280,8 @@ def test_conflict_resolved(tmpdir):
check("conflict runner used", labels == ["hermes_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)
# Reset the fakes: fake_finish + the unmerged flip leak into test 5.
_install_git_fake()
def test_conflict_failed_skips_and_dedupes(tmpdir): def test_conflict_failed_skips_and_dedupes(tmpdir):
@@ -540,6 +554,119 @@ def test_hermes_api_client(tmpdir):
W.API_SERVER_URL = "http://127.0.0.1:8642/v1" W.API_SERVER_URL = "http://127.0.0.1:8642/v1"
# ── 15. salvage + marker guard ────────────────────────────────────────────
def test_session_header_sent(tmpdir):
"""X-Hermes-Session-Id must be sent so each fork gets a fresh transcript."""
print("17. X-Hermes-Session-Id is sent per fork")
captured = {}
class FakeResp:
status_code = 200
def json(self):
return {"choices": [{"message": {"content": "done"}}]}
import httpx as _httpx
import sys as _sys
class FakeClient:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def post(self, url, headers, json):
captured["headers"] = headers
captured["url"] = url
return FakeResp()
_sys.modules["httpx"] = type("H", (), {"Client": FakeClient})
ok, snippet = W._hermes_api_post("resolve", pathlib.Path(tmpdir), session_id="sync_x_y")
check("session header sent", captured["headers"].get("X-Hermes-Session-Id") == "sync_x_y",
str(captured.get("headers")))
# Without session_id the header must be absent.
captured.clear()
ok2, _ = W._hermes_api_post("x", pathlib.Path(tmpdir), session_id=None)
check("session header absent when not passed",
"X-Hermes-Session-Id" not in captured["headers"], str(captured.get("headers")))
_sys.modules["httpx"] = _httpx
# ── 15. salvage + marker guard ────────────────────────────────────────────
def test_salvage_on_agent_timeout(tmpdir):
"""A timed-out agent call must NOT throw away a merge the agent already
finished — resolving many conflicts legitimately exceeds the HTTP timeout."""
print("15. salvage a completed merge after an agent timeout")
make_state_file(tmpdir)
state = {}
gitstate = _install_git_fake(merge_code=1, unmerged=[]) # agent resolved everything
labels = []
# The merge reports conflicts (so the worker enters the conflict path), but
# by the time the agent call ends the agent HAS resolved everything — the
# listing flips to empty when the fake runner is invoked.
resolved = {"done": False}
def fake_runner(workdir, prompt, label, fork, dry=False):
labels.append(label)
resolved["done"] = True # agent finished the work…
return False, "[INFRA] Hermes API server timed out after 2700s" # …but the HTTP call timed out
W._run_claude_sync = fake_runner
W._sync_unmerged_files = lambda workdir: [] if resolved["done"] else ["src/tools.ts"]
W._sync_has_conflict_markers = lambda workdir: []
pushes = []
W._push_ref = lambda workdir, fork, source, dest, app_token, force=False: (
pushes.append(dest), (True, "pat push ok", False))[1]
res, detail = W.sync_fork_repo("tok", "asepharyana/shiro-neko", "zakirkun/shiro-neko",
"main", "main", "up1", 4, 26, state, W.sync_config("x"))
check("timeout after a finished merge is salvaged, not failed",
res == "synced", f"{res} {detail}")
check("salvage still pushes", pushes and pushes[0] == "refs/heads/main", str(pushes))
check("salvage note mentions the salvage", "salvag" in detail.lower(), detail)
# …but a genuinely unfinished merge still fails and aborts.
print("15b. unfinished merge after a timeout still fails")
make_state_file(tmpdir)
state2 = {}
gitstate2 = _install_git_fake(merge_code=1, unmerged=["src/tools.ts"])
W._sync_unmerged_files = lambda workdir: ["src/tools.ts"]
W._sync_has_conflict_markers = lambda workdir: ["src/tools.ts"]
res2, detail2 = W.sync_fork_repo("tok", "f/x", "up/x", "main", "main", "up1", 4, 26,
state2, W.sync_config("x"))
check("unfinished merge is still a failure", res2 == "conflict-failed", f"{res2} {detail2}")
check("unfinished merge is aborted", gitstate2["aborts"] >= 1, str(gitstate2))
def test_finish_merge_blocks_leftover_markers(tmpdir):
"""`git add`ed files that still carry markers must never be committed."""
print("16. _sync_finish_merge refuses files with leftover conflict markers")
make_state_file(tmpdir)
_install_git_fake() # reset fakes; test 15b leaves a leaky spy behind
W._sync_unmerged_files = lambda workdir: []
W._sync_has_conflict_markers = lambda workdir: ["src/App.tsx"]
commits = []
real_git = W._sync_git
def git_spy(args, workdir, timeout):
if str(args[0]) == "commit":
commits.append(list(args))
return real_git(args, workdir, timeout)
W._sync_git = git_spy
ok, why = W._sync_finish_merge(pathlib.Path(tmpdir))
check("marker guard blocks the commit", (not ok) and "marker" in why, f"{ok} {why}")
check("no commit attempted", not commits, str(commits))
W._sync_git = real_git
W._sync_has_conflict_markers = lambda workdir: []
W._sync_unmerged_files = lambda workdir: []
def main(): def main():
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
test_upstream_status_parsing() test_upstream_status_parsing()
@@ -557,6 +684,9 @@ def main():
test_push_error_classification() test_push_error_classification()
test_sync_config_override() test_sync_config_override()
test_hermes_api_client(pathlib.Path(tmpdir)) test_hermes_api_client(pathlib.Path(tmpdir))
test_session_header_sent(tmpdir)
test_salvage_on_agent_timeout(tmpdir)
test_finish_merge_blocks_leftover_markers(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))