hermes: correct provider status and session lineage
Some checks failed
Tests / Declarative: Post Actions failed: 4, passed: 260

This commit is contained in:
jenkins 2026-08-12 23:48:06 -03:00
parent f3498bf2ea
commit 24221636b2
6 changed files with 240 additions and 20 deletions

View File

@ -459,6 +459,9 @@ spec:
- {name: HERMES_MEDIA_DELIVERY_STRICT, value: "1"} - {name: HERMES_MEDIA_DELIVERY_STRICT, value: "1"}
- {name: HERMES_MEDIA_ALLOW_DIRS, value: /opt/data/workspace} - {name: HERMES_MEDIA_ALLOW_DIRS, value: /opt/data/workspace}
- {name: HERMES_AUTO_ROUTER_PROFILE, value: agent} - {name: HERMES_AUTO_ROUTER_PROFILE, value: agent}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth} - {name: provider-auth, mountPath: /shared-auth}
@ -474,6 +477,7 @@ spec:
- {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_server.py} - {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
startupProbe: startupProbe:
exec: exec:
@ -615,6 +619,9 @@ spec:
- {name: AGENT_BROWSER_ARGS, value: "--no-sandbox,--disable-dev-shm-usage"} - {name: AGENT_BROWSER_ARGS, value: "--no-sandbox,--disable-dev-shm-usage"}
- {name: HERMES_TUI_AGENT_INIT_TIMEOUT_S, value: "180"} - {name: HERMES_TUI_AGENT_INIT_TIMEOUT_S, value: "180"}
- {name: HERMES_AUTO_ROUTER_PROFILE, value: agent} - {name: HERMES_AUTO_ROUTER_PROFILE, value: agent}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth} - {name: provider-auth, mountPath: /shared-auth}
@ -629,6 +636,7 @@ spec:
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py} - {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true} - {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true} - {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true} - {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
startupProbe: startupProbe:
@ -798,6 +806,7 @@ spec:
- {name: CODEX_HOME, value: /opt/data/home/.codex} - {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: PYTHONPATH, value: /opt/hermes} - {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"} - {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json} - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
readinessProbe: readinessProbe:
tcpSocket: {port: codex-broker} tcpSocket: {port: codex-broker}

View File

@ -48,7 +48,13 @@
h("span", { className: account.authenticated ? "is-good" : "is-bad" }, authLabel), h("span", { className: account.authenticated ? "is-good" : "is-bad" }, authLabel),
account.rate_limit_tier && account.rate_limit_tier !== "unknown" ? h("span", null, "Tier: " + account.rate_limit_tier) : null, account.rate_limit_tier && account.rate_limit_tier !== "unknown" ? h("span", null, "Tier: " + account.rate_limit_tier) : null,
h("span", null, "Access token: " + when(account.token_expires_at)), h("span", null, "Access token: " + when(account.token_expires_at)),
nativeHealth && nativeHealth.transport ? h("span", null, "Transport: native Claude Code subscription") : null, nativeHealth && nativeHealth.transport ? h("span", null,
nativeHealth.transport === "claude-code-cli-subscription"
? "Transport: native Claude Code subscription"
: nativeHealth.transport === "codex-chatgpt-subscription"
? "Transport: native ChatGPT Codex subscription"
: "Transport: " + nativeHealth.transport
) : null,
nativeHealth && nativeHealth.rate_limit && nativeHealth.rate_limit.utilization != null nativeHealth && nativeHealth.rate_limit && nativeHealth.rate_limit.utilization != null
? h("span", null, "Observed utilization: " + Math.round(Number(nativeHealth.rate_limit.utilization) * 100) + "%") ? h("span", null, "Observed utilization: " + Math.round(Number(nativeHealth.rate_limit.utilization) * 100) + "%")
: null, : null,

View File

@ -33,6 +33,9 @@ CLAUDE_HEALTH_PATH = Path(
"HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json" "HERMES_CLAUDE_HEALTH_PATH", "/opt/data/provider-health/claude.json"
) )
) )
CODEX_HEALTH_PATH = Path(
os.environ.get("HERMES_CODEX_HEALTH_PATH", "/opt/data/provider-health/codex.json")
)
def _read_json(path: Path) -> dict[str, Any]: def _read_json(path: Path) -> dict[str, Any]:
@ -251,6 +254,18 @@ def _fresh_health(path: Path, maximum_age: float = 86400.0) -> dict[str, Any]:
return value if age <= maximum_age else {} return value if age <= maximum_age else {}
def _apply_native_health(provider: dict[str, Any], health: dict[str, Any]) -> None:
"""Prefer current native transport health over lifetime router counters."""
provider["native_health"] = health
native_state = health.get("state")
if native_state == "available":
provider["state"] = "available"
elif native_state in {"capacity-limited", "degraded"}:
provider["state"] = "degraded"
elif native_state == "unavailable":
provider["state"] = "unavailable"
def provider_status_payload() -> dict[str, Any]: def provider_status_payload() -> dict[str, Any]:
"""Build the owner-safe status document shared by dashboard and TUI.""" """Build the owner-safe status document shared by dashboard and TUI."""
health = _get_json(f"{SWITCHYARD_ROOT}/health") health = _get_json(f"{SWITCHYARD_ROOT}/health")
@ -270,15 +285,8 @@ def provider_status_payload() -> dict[str, Any]:
) )
providers["codex"]["account"] = _codex_account() providers["codex"]["account"] = _codex_account()
providers["claude"]["account"] = _claude_account() providers["claude"]["account"] = _claude_account()
claude_health = _fresh_health(CLAUDE_HEALTH_PATH) _apply_native_health(providers["codex"], _fresh_health(CODEX_HEALTH_PATH))
providers["claude"]["native_health"] = claude_health _apply_native_health(providers["claude"], _fresh_health(CLAUDE_HEALTH_PATH))
native_state = claude_health.get("state")
if native_state == "available":
providers["claude"]["state"] = "available"
elif native_state == "capacity-limited":
providers["claude"]["state"] = "degraded"
elif native_state == "unavailable":
providers["claude"]["state"] = "unavailable"
classifier = stats.get("classifier") classifier = stats.get("classifier")
classifier = classifier if isinstance(classifier, dict) else {} classifier = classifier if isinstance(classifier, dict) else {}
fallbacks = stats.get("routing_fallbacks") fallbacks = stats.get("routing_fallbacks")

View File

@ -11,6 +11,7 @@ import json
import os import os
import tempfile import tempfile
import time import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -44,6 +45,54 @@ ROUTED_MODEL_PREFIX = "route/codex/"
TOKEN_REFRESH_SKEW_SECONDS = int( TOKEN_REFRESH_SKEW_SECONDS = int(
os.environ.get("HERMES_CODEX_BROKER_REFRESH_SKEW_SECONDS", "300") os.environ.get("HERMES_CODEX_BROKER_REFRESH_SKEW_SECONDS", "300")
) )
HEALTH_PATH = Path(
os.environ.get("HERMES_CODEX_HEALTH_PATH", "/opt/data/provider-health/codex.json")
)
def _previous_health() -> dict[str, Any]:
"""Read the previous non-secret native transport health snapshot."""
try:
value = json.loads(HEALTH_PATH.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _record_health(state: str, **updates: Any) -> None:
"""Atomically publish current first-party Codex health without credentials."""
value = _previous_health()
value.update(
{
"transport": "codex-chatgpt-subscription",
"state": state,
"checked_at": datetime.now(timezone.utc).isoformat(),
"authenticated": state != "unavailable",
**updates,
}
)
try:
HEALTH_PATH.parent.mkdir(parents=True, exist_ok=True)
temporary = HEALTH_PATH.with_name(f".{HEALTH_PATH.name}.{os.getpid()}.tmp")
temporary.write_text(
json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
os.replace(temporary, HEALTH_PATH)
except OSError:
pass
def _requested_effort(payload: dict[str, Any]) -> str:
"""Return the bounded Codex reasoning effort carried by a routed request."""
reasoning = payload.get("reasoning")
if isinstance(reasoning, dict) and reasoning.get("effort") in {
"low",
"medium",
"high",
"xhigh",
}:
return str(reasoning["effort"])
return "medium"
def _real_model(model: str) -> str: def _real_model(model: str) -> str:
@ -409,10 +458,21 @@ class Handler(BaseHTTPRequestHandler):
return return
if self.path == "/health": if self.path == "/health":
try: try:
_access_token() token = _access_token()
except RuntimeError as exc: except RuntimeError as exc:
_record_health(
"unavailable",
authenticated=False,
last_error_at=datetime.now(timezone.utc).isoformat(),
error_type=type(exc).__name__,
)
self._json(503, {"ok": False, "error": str(exc)}) self._json(503, {"ok": False, "error": str(exc)})
return return
_record_health(
"available",
authenticated=True,
token_expires_at=_token_expiry(token) or None,
)
self._json(200, {"ok": True, "provider": "openai-codex"}) self._json(200, {"ok": True, "provider": "openai-codex"})
return return
if self.path in {"/models", "/v1/models"}: if self.path in {"/models", "/v1/models"}:
@ -448,6 +508,9 @@ class Handler(BaseHTTPRequestHandler):
payload = json.loads(self.rfile.read(length)) payload = json.loads(self.rfile.read(length))
requested_stream = payload.get("stream") is True requested_stream = payload.get("stream") is True
payload = _validate_payload(payload) payload = _validate_payload(payload)
model = str(payload["model"])
effort = _requested_effort(payload)
started = time.monotonic()
token = _access_token() token = _access_token()
timeout = httpx.Timeout( timeout = httpx.Timeout(
READ_TIMEOUT_SECONDS, READ_TIMEOUT_SECONDS,
@ -460,6 +523,20 @@ class Handler(BaseHTTPRequestHandler):
with client.stream("POST", f"{UPSTREAM}/responses", json=payload) as response: with client.stream("POST", f"{UPSTREAM}/responses", json=payload) as response:
if response.status_code >= 400: if response.status_code >= 400:
body = response.read() body = response.read()
state = (
"capacity-limited"
if response.status_code == 429
else "unavailable"
if response.status_code in {401, 403}
else "degraded"
)
_record_health(
state,
last_error_at=datetime.now(timezone.utc).isoformat(),
status_code=response.status_code,
model=model,
effort=effort,
)
self.send_response(response.status_code) self.send_response(response.status_code)
self.send_header("Content-Type", response.headers.get("Content-Type", "application/json")) self.send_header("Content-Type", response.headers.get("Content-Type", "application/json"))
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@ -483,6 +560,14 @@ class Handler(BaseHTTPRequestHandler):
completed = _completed_response( completed = _completed_response(
body.decode("utf-8", errors="replace").splitlines() body.decode("utf-8", errors="replace").splitlines()
) )
_record_health(
"available",
last_success_at=datetime.now(timezone.utc).isoformat(),
latency_ms=int((time.monotonic() - started) * 1000),
model=model,
effort=effort,
token_expires_at=_token_expiry(token) or None,
)
if not requested_stream: if not requested_stream:
self._json(200, completed) self._json(200, completed)
return return
@ -505,6 +590,11 @@ class Handler(BaseHTTPRequestHandler):
except Exception as exc: except Exception as exc:
if response_started: if response_started:
return return
_record_health(
"degraded",
last_error_at=datetime.now(timezone.utc).isoformat(),
error_type=type(exc).__name__,
)
self._json( self._json(
502, 502,
{ {
@ -524,6 +614,21 @@ def main() -> None:
"""Serve until Kubernetes terminates the sidecar.""" """Serve until Kubernetes terminates the sidecar."""
if not TOKEN: if not TOKEN:
raise SystemExit("HERMES_IMAGE_BROKER_KEY is required") raise SystemExit("HERMES_IMAGE_BROKER_KEY is required")
try:
token = _access_token()
except RuntimeError as exc:
_record_health(
"unavailable",
authenticated=False,
last_error_at=datetime.now(timezone.utc).isoformat(),
error_type=type(exc).__name__,
)
else:
_record_health(
"available",
authenticated=True,
token_expires_at=_token_expiry(token) or None,
)
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

View File

@ -19,6 +19,9 @@ LEGACY_CASSANDRA_WORKERS = {
"api-c288b9f3024a91b8": "Cassandra stale-worktree check", "api-c288b9f3024a91b8": "Cassandra stale-worktree check",
"api-058e51802b58ee3f": "Cassandra verification worker", "api-058e51802b58ee3f": "Cassandra verification worker",
} }
LEGACY_ORPHANED_SMOKE_SESSIONS = {
"e17f2d888689": "Archived agent workspace smoke test",
}
def migrate(path: Path = STATE_DB) -> int: def migrate(path: Path = STATE_DB) -> int:
@ -30,8 +33,7 @@ def migrate(path: Path = STATE_DB) -> int:
parent = connection.execute( parent = connection.execute(
"SELECT id FROM sessions WHERE id = ?", (LEGACY_CASSANDRA_PARENT,) "SELECT id FROM sessions WHERE id = ?", (LEGACY_CASSANDRA_PARENT,)
).fetchone() ).fetchone()
if not parent: if parent:
return 0
for session_id, title in LEGACY_CASSANDRA_WORKERS.items(): for session_id, title in LEGACY_CASSANDRA_WORKERS.items():
cursor = connection.execute( cursor = connection.execute(
""" """
@ -45,6 +47,20 @@ def migrate(path: Path = STATE_DB) -> int:
(LEGACY_CASSANDRA_PARENT, title, session_id), (LEGACY_CASSANDRA_PARENT, title, session_id),
) )
changed += cursor.rowcount changed += cursor.rowcount
for session_id, title in LEGACY_ORPHANED_SMOKE_SESSIONS.items():
cursor = connection.execute(
"""
UPDATE sessions
SET archived = 1,
title = ?
WHERE id = ?
AND source = 'api_server'
AND parent_session_id IS NULL
AND archived = 0
""",
(title, session_id),
)
changed += cursor.rowcount
return changed return changed

View File

@ -927,6 +927,52 @@ def test_claude_broker_uses_native_subscription_without_api_billing(monkeypatch)
assert module.CAPACITY_PATTERN.search("weekly usage limit exhausted") assert module.CAPACITY_PATTERN.search("weekly usage limit exhausted")
def test_codex_native_health_overrides_historical_router_errors(
tmp_path: Path, monkeypatch
):
"""Fresh first-party health is authoritative over old Switchyard probes."""
plugin_path = HERMES / "plugins" / "auto-router" / "provider_status.py"
spec = importlib.util.spec_from_file_location("hermes_provider_status", plugin_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
health_path = tmp_path / "codex.json"
health_path.write_text(
json.dumps(
{
"state": "available",
"authenticated": True,
"transport": "codex-chatgpt-subscription",
}
)
)
monkeypatch.setattr(module, "CODEX_HEALTH_PATH", health_path)
monkeypatch.setattr(module, "CLAUDE_HEALTH_PATH", tmp_path / "missing.json")
monkeypatch.setattr(
module,
"_get_json",
lambda url: {"status": "ok"}
if url.endswith("/health")
else {
"models": {
"route/codex/terra/medium": {
"calls": 1,
"errors": 99,
"total_tokens": 12,
}
}
},
)
monkeypatch.setattr(module, "_codex_account", lambda: {})
monkeypatch.setattr(module, "_claude_account", lambda: {})
codex = module.provider_status_payload()["providers"]["codex"]
assert codex["errors"] == 99
assert codex["state"] == "available"
assert codex["native_health"]["transport"] == "codex-chatgpt-subscription"
def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
"""API-created workers must persist the originating Hermes session.""" """API-created workers must persist the originating Hermes session."""
module_path = HERMES / "scripts" / "patch_api_server_sessions.py" module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
@ -957,19 +1003,27 @@ def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
with sqlite3.connect(database) as connection: with sqlite3.connect(database) as connection:
connection.execute( connection.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, " "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"parent_session_id TEXT, title TEXT, transcript TEXT)" "parent_session_id TEXT, title TEXT, transcript TEXT, archived INTEGER DEFAULT 0)"
) )
connection.execute( connection.execute(
"INSERT INTO sessions VALUES (?, 'tui', NULL, 'Cassandra', 'parent-data')", "INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
"VALUES (?, 'tui', NULL, 'Cassandra', 'parent-data')",
(module.LEGACY_CASSANDRA_PARENT,), (module.LEGACY_CASSANDRA_PARENT,),
) )
worker_id = next(iter(module.LEGACY_CASSANDRA_WORKERS)) worker_id = next(iter(module.LEGACY_CASSANDRA_WORKERS))
connection.execute( connection.execute(
"INSERT INTO sessions VALUES (?, 'api_server', NULL, 'old', 'keep-me')", "INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
"VALUES (?, 'api_server', NULL, 'old', 'keep-me')",
(worker_id,), (worker_id,),
) )
orphan_id = next(iter(module.LEGACY_ORPHANED_SMOKE_SESSIONS))
connection.execute(
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
"VALUES (?, 'api_server', NULL, 'old smoke', 'keep-smoke')",
(orphan_id,),
)
assert module.migrate(database) == 1 assert module.migrate(database) == 2
assert module.migrate(database) == 0 assert module.migrate(database) == 0
with sqlite3.connect(database) as connection: with sqlite3.connect(database) as connection:
row = connection.execute( row = connection.execute(
@ -981,6 +1035,16 @@ def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
module.LEGACY_CASSANDRA_WORKERS[worker_id], module.LEGACY_CASSANDRA_WORKERS[worker_id],
"keep-me", "keep-me",
) )
with sqlite3.connect(database) as connection:
orphan = connection.execute(
"SELECT archived, title, transcript FROM sessions WHERE id = ?",
(orphan_id,),
).fetchone()
assert orphan == (
1,
module.LEGACY_ORPHANED_SMOKE_SESSIONS[orphan_id],
"keep-smoke",
)
def test_switchyard_brokers_and_native_claude_lane_use_the_right_images(): def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
@ -1008,6 +1072,18 @@ def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():
container["name"]: container container["name"]: container
for container in agent["spec"]["template"]["spec"]["containers"] for container in agent["spec"]["template"]["spec"]["containers"]
} }
for container_name in ("hermes", "terminal"):
container = agent_containers[container_name]
environment = {item["name"]: item["value"] for item in container["env"]}
mounts = {item["name"]: item for item in container["volumeMounts"]}
assert environment["HERMES_ROUTING_CATALOG_PATH"] == "/routing-catalog/catalog.json"
assert environment["HERMES_CODEX_HEALTH_PATH"] == "/opt/data/provider-health/codex.json"
assert environment["HERMES_CLAUDE_HEALTH_PATH"] == "/opt/data/provider-health/claude.json"
assert mounts["routing-catalog"]["mountPath"] == "/routing-catalog"
assert mounts["routing-catalog"]["readOnly"] is True
codex = agent_containers["codex-broker"]
codex_environment = {item["name"]: item["value"] for item in codex["env"]}
assert codex_environment["HERMES_CODEX_HEALTH_PATH"] == "/opt/data/provider-health/codex.json"
claude = agent_containers["claude-broker"] claude = agent_containers["claude-broker"]
assert claude["image"].startswith("registry.bstein.dev/bstein/hermes-agent@") assert claude["image"].startswith("registry.bstein.dev/bstein/hermes-agent@")
assert "unset ANTHROPIC_API_KEY CLAUDE_API_KEY" in claude["args"][0] assert "unset ANTHROPIC_API_KEY CLAUDE_API_KEY" in claude["args"][0]