hermes: keep provider lanes available under load

This commit is contained in:
jenkins 2026-08-15 22:15:06 -03:00
parent 3b291d0e84
commit e9efec4ddf
5 changed files with 64 additions and 4 deletions

View File

@ -1045,14 +1045,15 @@ spec:
- {name: HERMES_CLAUDE_BROKER_PORT, value: "9006"} - {name: HERMES_CLAUDE_BROKER_PORT, value: "9006"}
- {name: HERMES_CLAUDE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key} - {name: HERMES_CLAUDE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_CLAUDE_BROKER_READ_TIMEOUT, value: "1800"} - {name: HERMES_CLAUDE_BROKER_READ_TIMEOUT, value: "1800"}
- {name: HERMES_CLAUDE_BROKER_CONCURRENCY, value: "2"}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json} - {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json} - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
readinessProbe: readinessProbe:
httpGet: {path: /health, port: claude-broker} tcpSocket: {port: claude-broker}
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
livenessProbe: livenessProbe:
httpGet: {path: /health, port: claude-broker} tcpSocket: {port: claude-broker}
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
securityContext: securityContext:

View File

@ -525,7 +525,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.end_headers() self.end_headers()
self.wfile.write(body) try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
# Kubernetes and callers may close a health/request socket after
# their own deadline. The broker is still healthy and must not
# emit a noisy handler traceback for that normal disconnect.
return
def _error(self, status: int, error_type: str, message: str) -> None: def _error(self, status: int, error_type: str, message: str) -> None:
self._json( self._json(
@ -623,7 +629,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
return
else: else:
self._json(200, message) self._json(200, message)

View File

@ -88,6 +88,7 @@ metadata:
app: hermes-agent app: hermes-agent
spec: spec:
type: ClusterIP type: ClusterIP
publishNotReadyAddresses: true
selector: selector:
app: hermes-agent app: hermes-agent
ports: ports:
@ -105,6 +106,7 @@ metadata:
app: hermes-agent app: hermes-agent
spec: spec:
type: ClusterIP type: ClusterIP
publishNotReadyAddresses: true
selector: selector:
app: hermes-agent app: hermes-agent
ports: ports:
@ -122,6 +124,7 @@ metadata:
app: hermes-local-image app: hermes-local-image
spec: spec:
type: ClusterIP type: ClusterIP
publishNotReadyAddresses: true
selector: selector:
app: hermes-local-image app: hermes-local-image
ports: ports:
@ -139,6 +142,7 @@ metadata:
app: hermes-agent app: hermes-agent
spec: spec:
type: ClusterIP type: ClusterIP
publishNotReadyAddresses: true
selector: selector:
app: hermes-agent app: hermes-agent
ports: ports:

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import importlib.util import importlib.util
import sys import sys
from io import BytesIO
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@ -99,3 +100,22 @@ def test_unadvertised_tool_call_is_retryable(monkeypatch) -> None:
}, },
_payload(), _payload(),
) )
@pytest.mark.parametrize("disconnect", [BrokenPipeError, ConnectionResetError])
def test_json_response_tolerates_disconnected_client(monkeypatch, disconnect) -> None:
module = _module(monkeypatch)
handler = object.__new__(module.Handler)
handler.request_version = "HTTP/1.1"
handler.command = "GET"
handler.send_response = lambda _status: None
handler.send_header = lambda _name, _value: None
handler.end_headers = lambda: None
class Disconnected(BytesIO):
def write(self, _body):
raise disconnect
handler.wfile = Disconnected()
handler._json(200, {"ok": True})

View File

@ -47,6 +47,14 @@ def _agent_deployment() -> dict:
return yaml.safe_load((HERMES / "agent-deployment.yaml").read_text()) return yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())
def _services() -> dict[str, dict]:
return {
item["metadata"]["name"]: item
for item in yaml.safe_load_all((HERMES / "service.yaml").read_text())
if item
}
def _oauth_deployment(name: str) -> dict: def _oauth_deployment(name: str) -> dict:
documents = [ documents = [
item item
@ -718,6 +726,12 @@ def test_agent_root_is_stock_dashboard_and_terminal_is_a_separate_path():
terminal_env = {item["name"]: item["value"] for item in terminal["env"]} terminal_env = {item["name"]: item["value"] for item in terminal["env"]}
assert terminal_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180" assert terminal_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180"
claude_broker = containers["claude-broker"]
claude_env = {item["name"]: item["value"] for item in claude_broker["env"]}
assert claude_env["HERMES_CLAUDE_BROKER_CONCURRENCY"] == "2"
assert claude_broker["readinessProbe"]["tcpSocket"] == {"port": "claude-broker"}
assert claude_broker["livenessProbe"]["tcpSocket"] == {"port": "claude-broker"}
args = containers["oauth2-proxy"]["args"] args = containers["oauth2-proxy"]["args"]
terminal_upstream = "--upstream=http://127.0.0.1:7681/terminal/" terminal_upstream = "--upstream=http://127.0.0.1:7681/terminal/"
dashboard_upstream = "--upstream=http://127.0.0.1:9119/" dashboard_upstream = "--upstream=http://127.0.0.1:9119/"
@ -774,6 +788,18 @@ def test_agent_root_is_stock_dashboard_and_terminal_is_a_separate_path():
] == "hermes-hermes-agent-terminal-slash@kubernetescrd" ] == "hermes-hermes-agent-terminal-slash@kubernetescrd"
def test_broker_services_survive_sibling_container_readiness_loss():
services = _services()
for name in (
"hermes-image-broker",
"hermes-codex-broker",
"hermes-local-image",
"hermes-claude-broker",
):
assert services[name]["spec"]["publishNotReadyAddresses"] is True
def test_agent_dashboard_reconnects_all_transient_websockets(): def test_agent_dashboard_reconnects_all_transient_websockets():
dockerfile = ( dockerfile = (
HERMES.parents[1] / "dockerfiles/Dockerfile.hermes-agent" HERMES.parents[1] / "dockerfiles/Dockerfile.hermes-agent"