hermes: avoid unavailable worker providers
This commit is contained in:
parent
c0eda84fa1
commit
18eeeabb62
@ -64,6 +64,9 @@ _slots = threading.BoundedSemaphore(MAX_CONCURRENCY)
|
|||||||
_auth_probe_lock = threading.Lock()
|
_auth_probe_lock = threading.Lock()
|
||||||
_auth_probe_at = 0.0
|
_auth_probe_at = 0.0
|
||||||
_auth_probe_value: dict[str, Any] = {}
|
_auth_probe_value: dict[str, Any] = {}
|
||||||
|
HEALTH_POLL_SECONDS: Final = max(
|
||||||
|
30, int(os.environ.get("HERMES_CLAUDE_HEALTH_POLL_SECONDS", "60"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_secret(file_env_name: str) -> str:
|
def _read_secret(file_env_name: str) -> str:
|
||||||
@ -313,6 +316,13 @@ def _subscription_health(force: bool = False) -> dict[str, Any]:
|
|||||||
return dict(value)
|
return dict(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _health_polling_loop() -> None:
|
||||||
|
"""Keep native auth health fresh even when no request reaches the broker."""
|
||||||
|
while True:
|
||||||
|
time.sleep(HEALTH_POLL_SECONDS)
|
||||||
|
_subscription_health(force=True)
|
||||||
|
|
||||||
|
|
||||||
def _invoke(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], str]:
|
def _invoke(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], str]:
|
||||||
"""Run one native, first-party Claude Code subscription request."""
|
"""Run one native, first-party Claude Code subscription request."""
|
||||||
requested = payload.get("model")
|
requested = payload.get("model")
|
||||||
@ -645,4 +655,5 @@ class Server(ThreadingHTTPServer):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
_subscription_health(force=True)
|
_subscription_health(force=True)
|
||||||
|
threading.Thread(target=_health_polling_loop, daemon=True).start()
|
||||||
Server((HOST, PORT), Handler).serve_forever()
|
Server((HOST, PORT), Handler).serve_forever()
|
||||||
|
|||||||
@ -37,6 +37,11 @@ EXTERNAL_PREFIX = "cli-"
|
|||||||
DEFAULT_CLAIM_TTL = 7 * 24 * 60 * 60
|
DEFAULT_CLAIM_TTL = 7 * 24 * 60 * 60
|
||||||
DEFAULT_MAX_RUNTIME = 12 * 60 * 60
|
DEFAULT_MAX_RUNTIME = 12 * 60 * 60
|
||||||
HEARTBEAT_SECONDS = 20
|
HEARTBEAT_SECONDS = 20
|
||||||
|
PROVIDER_HEALTH_MAX_AGE_SECONDS = 5 * 60
|
||||||
|
PROVIDER_HEALTH_PATHS = {
|
||||||
|
"codex": DATA_ROOT / "provider-health/codex.json",
|
||||||
|
"claude": DATA_ROOT / "provider-health/claude.json",
|
||||||
|
}
|
||||||
WORKTREE_LOCK = threading.Lock()
|
WORKTREE_LOCK = threading.Lock()
|
||||||
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
|
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
|
||||||
CAPACITY_PATTERN = re.compile(
|
CAPACITY_PATTERN = re.compile(
|
||||||
@ -119,6 +124,26 @@ def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
|
|||||||
return match.group(1), match.group(2)
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_unavailable_provider(now: float | None = None) -> str | None:
|
||||||
|
"""Return one recently proven-down provider for automatic route exclusion."""
|
||||||
|
current = time.time() if now is None else now
|
||||||
|
unavailable: list[str] = []
|
||||||
|
for provider, path in PROVIDER_HEALTH_PATHS.items():
|
||||||
|
health = load_json(path)
|
||||||
|
try:
|
||||||
|
age = current - path.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
0 <= age <= PROVIDER_HEALTH_MAX_AGE_SECONDS
|
||||||
|
and health.get("state") == "unavailable"
|
||||||
|
):
|
||||||
|
unavailable.append(provider)
|
||||||
|
# If both providers are down, keep the normal attempt/fallback path so the
|
||||||
|
# card records authoritative current errors instead of trusting snapshots.
|
||||||
|
return unavailable[0] if len(unavailable) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def _decode_worker_target(value: str) -> tuple[str, str, str]:
|
def _decode_worker_target(value: str) -> tuple[str, str, str]:
|
||||||
"""Decode the selected model header emitted by a worker decision route."""
|
"""Decode the selected model header emitted by a worker decision route."""
|
||||||
parts = value.split("/", 3)
|
parts = value.split("/", 3)
|
||||||
@ -135,6 +160,7 @@ def select_route(
|
|||||||
assignee: str,
|
assignee: str,
|
||||||
*,
|
*,
|
||||||
exclude_provider: str | None = None,
|
exclude_provider: str | None = None,
|
||||||
|
exclude_reason: str | None = None,
|
||||||
switchyard_url: str = SWITCHYARD_URL,
|
switchyard_url: str = SWITCHYARD_URL,
|
||||||
open_request: Callable[..., Any] = urllib.request.urlopen,
|
open_request: Callable[..., Any] = urllib.request.urlopen,
|
||||||
) -> Route:
|
) -> Route:
|
||||||
@ -149,9 +175,10 @@ def select_route(
|
|||||||
source = "switchyard-classifier"
|
source = "switchyard-classifier"
|
||||||
context = prompt
|
context = prompt
|
||||||
if exclude_provider:
|
if exclude_provider:
|
||||||
|
reason = exclude_reason or "failed or exhausted capacity at this boundary"
|
||||||
context += (
|
context += (
|
||||||
f"\n\nRouting constraint: the {exclude_provider} provider failed or "
|
f"\n\nRouting constraint: the {exclude_provider} provider {reason}. "
|
||||||
"exhausted capacity at this boundary. Do not select it."
|
"Do not select it."
|
||||||
)
|
)
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{
|
{
|
||||||
@ -192,8 +219,25 @@ def select_route(
|
|||||||
except (AttributeError, IndexError, KeyError, RuntimeError, TypeError, ValueError, json.JSONDecodeError):
|
except (AttributeError, IndexError, KeyError, RuntimeError, TypeError, ValueError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
if exclude_provider and provider == exclude_provider:
|
if exclude_provider and provider == exclude_provider:
|
||||||
raise RuntimeError(
|
alternate = "claude" if provider == "codex" else "codex"
|
||||||
f"Switchyard selected excluded provider {exclude_provider} for retry"
|
guarded = select_route(
|
||||||
|
prompt,
|
||||||
|
f"cli-{alternate}-{effort}",
|
||||||
|
switchyard_url=switchyard_url,
|
||||||
|
open_request=open_request,
|
||||||
|
)
|
||||||
|
return Route(
|
||||||
|
provider=guarded.provider,
|
||||||
|
model=guarded.model,
|
||||||
|
effort=guarded.effort,
|
||||||
|
profile=guarded.profile,
|
||||||
|
classifier=f"{source}-health-guard",
|
||||||
|
reason=(
|
||||||
|
f"Switchyard selected excluded {exclude_provider}; preserved "
|
||||||
|
f"its {effort} effort on healthy-provider route. {guarded.reason}"
|
||||||
|
),
|
||||||
|
latency_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
fallback_chain=(),
|
||||||
)
|
)
|
||||||
return Route(
|
return Route(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@ -681,7 +725,24 @@ def execute_claim(board: str, task_id: str) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
previous_route = state.get("current_route")
|
previous_route = state.get("current_route")
|
||||||
route = select_route(context, assignee)
|
excluded_provider = (
|
||||||
|
fresh_unavailable_provider() if assignee == "cli-auto" else None
|
||||||
|
)
|
||||||
|
route = select_route(
|
||||||
|
context,
|
||||||
|
assignee,
|
||||||
|
exclude_provider=excluded_provider,
|
||||||
|
exclude_reason="is unavailable according to fresh native health"
|
||||||
|
if excluded_provider
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
if excluded_provider:
|
||||||
|
kanban_db.add_comment(
|
||||||
|
conn,
|
||||||
|
task_id,
|
||||||
|
"cli-lane-runner",
|
||||||
|
f"Provider health guard excluded {excluded_provider} before automatic routing.",
|
||||||
|
)
|
||||||
kanban_db.add_comment(
|
kanban_db.add_comment(
|
||||||
conn,
|
conn,
|
||||||
task_id,
|
task_id,
|
||||||
|
|||||||
@ -151,6 +151,51 @@ def test_cross_provider_retry_passes_failed_provider_to_switchyard():
|
|||||||
assert "codex provider failed or exhausted capacity" in content
|
assert "codex provider failed or exhausted capacity" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_cannot_select_a_freshly_excluded_provider():
|
||||||
|
payloads = []
|
||||||
|
|
||||||
|
def route(request, timeout):
|
||||||
|
payload = json.loads(request.data)
|
||||||
|
payloads.append(payload)
|
||||||
|
if len(payloads) == 1:
|
||||||
|
return _SwitchyardResponse("worker/claude/opus/xhigh")
|
||||||
|
return _SwitchyardResponse("worker/codex/sol/xhigh", "healthy route")
|
||||||
|
|
||||||
|
selected = lanes.select_route(
|
||||||
|
"Perform a consequential review.",
|
||||||
|
"cli-auto",
|
||||||
|
exclude_provider="claude",
|
||||||
|
exclude_reason="is unavailable according to fresh native health",
|
||||||
|
open_request=route,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selected.provider == "codex"
|
||||||
|
assert selected.effort == "xhigh"
|
||||||
|
assert selected.classifier == "switchyard-classifier-health-guard"
|
||||||
|
assert payloads[0]["model"] == "atlas/worker/auto/maximum"
|
||||||
|
assert payloads[1]["model"] == "atlas/worker/manual/codex/xhigh"
|
||||||
|
assert "claude provider is unavailable" in payloads[0]["messages"][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_native_health_excludes_only_one_proven_down_provider(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
paths = {
|
||||||
|
"codex": tmp_path / "codex.json",
|
||||||
|
"claude": tmp_path / "claude.json",
|
||||||
|
}
|
||||||
|
paths["codex"].write_text('{"state":"available"}\n', encoding="utf-8")
|
||||||
|
paths["claude"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
|
||||||
|
|
||||||
|
now = max(path.stat().st_mtime for path in paths.values())
|
||||||
|
assert lanes.fresh_unavailable_provider(now=now) == "claude"
|
||||||
|
|
||||||
|
paths["codex"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||||
|
now = max(path.stat().st_mtime for path in paths.values())
|
||||||
|
assert lanes.fresh_unavailable_provider(now=now) is None
|
||||||
|
|
||||||
|
|
||||||
def test_worker_environment_preserves_vault_backed_cli_homes(monkeypatch):
|
def test_worker_environment_preserves_vault_backed_cli_homes(monkeypatch):
|
||||||
"""Kanban workers must not fall back to credential-free persistent homes."""
|
"""Kanban workers must not fall back to credential-free persistent homes."""
|
||||||
monkeypatch.setenv("CODEX_HOME", "/runtime-access/codex")
|
monkeypatch.setenv("CODEX_HOME", "/runtime-access/codex")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user