hermes: align capacity gate with auth classifier
CAPACITY_PATTERN (the gate that sets result.capacity_failure) lacked the bare unauthorized/forbidden/401/403 signals that classify_capacity_failure already recognizes, so an auth blip surfacing only as "403 Forbidden" was blocked as capability instead of transient: no auto failover, no health cooldown. Add 401|403|unauthorized|forbidden to the gate so it matches the classifier; reason classification still distinguishes auth from quota/rate-limit/transport. Tests: an auto card failing with only "403 Forbidden" now fails over to the other provider, records an auth cooldown (authenticated:false), and classifies the fallback reason as auth in metrics; a manual card with the same failure still fails closed as transient. Router-outage tests moved to test_hermes_cli_router_outage.py to keep both files <500 LOC. Based on PR #15 (fix/hermes-result-decomposition-reliability). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6df688adcc
commit
a5d0abb678
@ -55,7 +55,8 @@ WORKTREE_LOCK = threading.Lock()
|
|||||||
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
|
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
|
||||||
LAST_ARTIFACT_GC = 0.0
|
LAST_ARTIFACT_GC = 0.0
|
||||||
CAPACITY_PATTERN = re.compile(
|
CAPACITY_PATTERN = re.compile(
|
||||||
r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529|authentication|oauth|token.*expired)",
|
r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529"
|
||||||
|
r"|authentication|unauthorized|forbidden|oauth|token.*expired|401|403)",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
NO_CLAUDE_SESSION = "No conversation found with session ID:"
|
NO_CLAUDE_SESSION = "No conversation found with session ID:"
|
||||||
|
|||||||
@ -196,6 +196,80 @@ def test_auto_failover_preserves_effort_and_records_cooldown(
|
|||||||
assert claude_health["state"] == "available"
|
assert claude_health["state"] == "available"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_forbidden_auth_blip_fails_over_and_records_auth_cooldown(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_forbidden",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=38,
|
||||||
|
assignee="cli-auto",
|
||||||
|
max_runtime_seconds=120,
|
||||||
|
)
|
||||||
|
comments: list = []
|
||||||
|
calls: list = []
|
||||||
|
board = _lane_board(tmp_path, task, comments, calls)
|
||||||
|
health_paths = _isolate_lane(tmp_path, monkeypatch, board)
|
||||||
|
|
||||||
|
def select_route(_prompt, lane, **_kwargs):
|
||||||
|
return (
|
||||||
|
_route("codex", "high") if lane == "cli-auto" else _route("claude", "high")
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||||
|
fallbacks: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"record_provider_fallback",
|
||||||
|
lambda source, target, reason: fallbacks.append((source, target, reason)),
|
||||||
|
)
|
||||||
|
# The failure surfaces only as "403 Forbidden" with none of the words the
|
||||||
|
# capacity gate previously keyed on (authentication/oauth/token expired).
|
||||||
|
reports = [
|
||||||
|
lanes.ProcessResult(1, "403 Forbidden", None, True),
|
||||||
|
lanes.ProcessResult(0, "done", dict(COMPLETED_RESULT), False),
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0))
|
||||||
|
|
||||||
|
lanes.execute_claim("cassandra", "t_forbidden")
|
||||||
|
|
||||||
|
# (a) failed over to the other provider and completed.
|
||||||
|
assert reports == []
|
||||||
|
assert calls and calls[0][0] == "complete"
|
||||||
|
# (c) the fallback reason classifies as auth in metrics.
|
||||||
|
assert fallbacks == [("codex", "claude", "auth")]
|
||||||
|
assert any(
|
||||||
|
"Provider fallback: codex -> claude after a auth failure" in item
|
||||||
|
for item in comments
|
||||||
|
)
|
||||||
|
# (b) an auth cooldown was recorded (authenticated:false, longer window).
|
||||||
|
codex_health = json.loads(health_paths["codex"].read_text())
|
||||||
|
assert codex_health["state"] == "unavailable"
|
||||||
|
assert codex_health["authenticated"] is False
|
||||||
|
assert codex_health["failure_reason"] == "auth"
|
||||||
|
assert codex_health["cooldown_until"] == codex_health["failed_at"] + 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_forbidden_auth_blip_still_fails_closed_on_manual_lane(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
calls, _comments, route_calls, runs, health_paths = _run_manual_fail_closed(
|
||||||
|
tmp_path, monkeypatch, "cli-codex-high", "codex", "403 Forbidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fail-closed is unchanged: the pinned provider is not switched.
|
||||||
|
assert runs == ["codex"]
|
||||||
|
assert [lane for lane, _ in route_calls] == ["cli-codex-high"]
|
||||||
|
kind, kwargs = calls[-1]
|
||||||
|
assert kind == "block" and kwargs["kind"] == "transient"
|
||||||
|
assert "Manually pinned provider codex" in kwargs["reason"]
|
||||||
|
assert "auth" in kwargs["reason"]
|
||||||
|
codex_health = json.loads(health_paths["codex"].read_text())
|
||||||
|
assert codex_health["state"] == "unavailable"
|
||||||
|
assert codex_health["authenticated"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_double_capacity_failure_blocks_transient_with_both_reasons(
|
def test_double_capacity_failure_blocks_transient_with_both_reasons(
|
||||||
tmp_path: Path, monkeypatch
|
tmp_path: Path, monkeypatch
|
||||||
):
|
):
|
||||||
@ -256,140 +330,6 @@ def test_double_capacity_failure_blocks_transient_with_both_reasons(
|
|||||||
assert claude_health["authenticated"] is False
|
assert claude_health["authenticated"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_router_outage_blocks_transient_before_any_provider_run(
|
|
||||||
tmp_path: Path, monkeypatch
|
|
||||||
):
|
|
||||||
task = SimpleNamespace(
|
|
||||||
id="t_router",
|
|
||||||
status="running",
|
|
||||||
result=None,
|
|
||||||
current_run_id=34,
|
|
||||||
assignee="cli-auto",
|
|
||||||
max_runtime_seconds=60,
|
|
||||||
)
|
|
||||||
comments: list = []
|
|
||||||
calls: list = []
|
|
||||||
board = _lane_board(tmp_path, task, comments, calls)
|
|
||||||
_isolate_lane(tmp_path, monkeypatch, board)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes,
|
|
||||||
"select_route",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
|
||||||
RuntimeError("Switchyard worker routing failed: refused")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
outages: list = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes, "record_router_selection_failure", lambda: outages.append(1)
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes,
|
|
||||||
"run_provider",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
|
||||||
AssertionError("no provider may run without a route")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
lanes.execute_claim("cassandra", "t_router")
|
|
||||||
|
|
||||||
assert outages == [1]
|
|
||||||
kind, kwargs = calls[-1]
|
|
||||||
assert kind == "block" and kwargs["kind"] == "transient"
|
|
||||||
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_router_outage_during_fallback_selection_blocks_transient(
|
|
||||||
tmp_path: Path, monkeypatch
|
|
||||||
):
|
|
||||||
task = SimpleNamespace(
|
|
||||||
id="t_router_fb",
|
|
||||||
status="running",
|
|
||||||
result=None,
|
|
||||||
current_run_id=35,
|
|
||||||
assignee="cli-auto",
|
|
||||||
max_runtime_seconds=60,
|
|
||||||
)
|
|
||||||
comments: list = []
|
|
||||||
calls: list = []
|
|
||||||
board = _lane_board(tmp_path, task, comments, calls)
|
|
||||||
_isolate_lane(tmp_path, monkeypatch, board)
|
|
||||||
selections: list = []
|
|
||||||
|
|
||||||
def select_route(_prompt, lane, **_kwargs):
|
|
||||||
selections.append(lane)
|
|
||||||
if lane == "cli-auto":
|
|
||||||
return _route("codex", "medium")
|
|
||||||
raise RuntimeError("Switchyard worker routing failed: refused")
|
|
||||||
|
|
||||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes,
|
|
||||||
"run_provider",
|
|
||||||
lambda *_args, **_kwargs: lanes.ProcessResult(1, "quota exceeded", None, True),
|
|
||||||
)
|
|
||||||
|
|
||||||
lanes.execute_claim("cassandra", "t_router_fb")
|
|
||||||
|
|
||||||
assert selections == ["cli-auto", "cli-claude-medium"]
|
|
||||||
kind, kwargs = calls[-1]
|
|
||||||
assert kind == "block" and kwargs["kind"] == "transient"
|
|
||||||
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_goal_loop_router_outage_blocks_transient(tmp_path: Path, monkeypatch):
|
|
||||||
task = SimpleNamespace(
|
|
||||||
id="t_goal_router",
|
|
||||||
status="running",
|
|
||||||
result=None,
|
|
||||||
current_run_id=36,
|
|
||||||
assignee="cli-auto",
|
|
||||||
max_runtime_seconds=300,
|
|
||||||
goal_mode=True,
|
|
||||||
goal_max_turns=3,
|
|
||||||
)
|
|
||||||
comments: list = []
|
|
||||||
calls: list = []
|
|
||||||
board = _lane_board(tmp_path, task, comments, calls)
|
|
||||||
_isolate_lane(tmp_path, monkeypatch, board)
|
|
||||||
selections: list = []
|
|
||||||
|
|
||||||
def select_route(_prompt, lane, **_kwargs):
|
|
||||||
selections.append(lane)
|
|
||||||
if len(selections) == 1:
|
|
||||||
return _route("codex", "high")
|
|
||||||
raise RuntimeError("Switchyard worker routing failed: refused")
|
|
||||||
|
|
||||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes,
|
|
||||||
"run_provider",
|
|
||||||
lambda *_args, **_kwargs: lanes.ProcessResult(
|
|
||||||
0, "turn", dict(COMPLETED_RESULT), False
|
|
||||||
),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes.cli_lane_goal,
|
|
||||||
"judge_goal_completion",
|
|
||||||
lambda *_args, **_kwargs: (False, "verification is still missing"),
|
|
||||||
)
|
|
||||||
continued: list = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
lanes,
|
|
||||||
"record_route_decision",
|
|
||||||
lambda provider, _effort, _classifier, outcome: continued.append(
|
|
||||||
(provider, outcome)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
lanes.execute_claim("cassandra", "t_goal_router")
|
|
||||||
|
|
||||||
assert selections == ["cli-auto", "cli-auto"]
|
|
||||||
assert ("codex", "goal-continued") in continued
|
|
||||||
kind, kwargs = calls[-1]
|
|
||||||
assert kind == "block" and kwargs["kind"] == "transient"
|
|
||||||
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_soft_quota_exclusion_steers_new_auto_work(tmp_path: Path, monkeypatch):
|
def test_soft_quota_exclusion_steers_new_auto_work(tmp_path: Path, monkeypatch):
|
||||||
task = SimpleNamespace(
|
task = SimpleNamespace(
|
||||||
id="t_quota",
|
id="t_quota",
|
||||||
|
|||||||
149
testing/tests/test_hermes_cli_router_outage.py
Normal file
149
testing/tests/test_hermes_cli_router_outage.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""Switchyard outage blocks a card as transient at every selection boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from testing.tests.test_hermes_cli_quota_failover import (
|
||||||
|
COMPLETED_RESULT,
|
||||||
|
_isolate_lane,
|
||||||
|
_lane_board,
|
||||||
|
_route,
|
||||||
|
)
|
||||||
|
from testing.tests.test_hermes_cli_support import (
|
||||||
|
Path,
|
||||||
|
SimpleNamespace,
|
||||||
|
lanes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_outage_blocks_transient_before_any_provider_run(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_router",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=34,
|
||||||
|
assignee="cli-auto",
|
||||||
|
max_runtime_seconds=60,
|
||||||
|
)
|
||||||
|
comments: list = []
|
||||||
|
calls: list = []
|
||||||
|
board = _lane_board(tmp_path, task, comments, calls)
|
||||||
|
_isolate_lane(tmp_path, monkeypatch, board)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"select_route",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||||
|
RuntimeError("Switchyard worker routing failed: refused")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outages: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes, "record_router_selection_failure", lambda: outages.append(1)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"run_provider",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||||
|
AssertionError("no provider may run without a route")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
lanes.execute_claim("cassandra", "t_router")
|
||||||
|
|
||||||
|
assert outages == [1]
|
||||||
|
kind, kwargs = calls[-1]
|
||||||
|
assert kind == "block" and kwargs["kind"] == "transient"
|
||||||
|
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_outage_during_fallback_selection_blocks_transient(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_router_fb",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=35,
|
||||||
|
assignee="cli-auto",
|
||||||
|
max_runtime_seconds=60,
|
||||||
|
)
|
||||||
|
comments: list = []
|
||||||
|
calls: list = []
|
||||||
|
board = _lane_board(tmp_path, task, comments, calls)
|
||||||
|
_isolate_lane(tmp_path, monkeypatch, board)
|
||||||
|
selections: list = []
|
||||||
|
|
||||||
|
def select_route(_prompt, lane, **_kwargs):
|
||||||
|
selections.append(lane)
|
||||||
|
if lane == "cli-auto":
|
||||||
|
return _route("codex", "medium")
|
||||||
|
raise RuntimeError("Switchyard worker routing failed: refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"run_provider",
|
||||||
|
lambda *_args, **_kwargs: lanes.ProcessResult(1, "quota exceeded", None, True),
|
||||||
|
)
|
||||||
|
|
||||||
|
lanes.execute_claim("cassandra", "t_router_fb")
|
||||||
|
|
||||||
|
assert selections == ["cli-auto", "cli-claude-medium"]
|
||||||
|
kind, kwargs = calls[-1]
|
||||||
|
assert kind == "block" and kwargs["kind"] == "transient"
|
||||||
|
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_loop_router_outage_blocks_transient(tmp_path: Path, monkeypatch):
|
||||||
|
task = SimpleNamespace(
|
||||||
|
id="t_goal_router",
|
||||||
|
status="running",
|
||||||
|
result=None,
|
||||||
|
current_run_id=36,
|
||||||
|
assignee="cli-auto",
|
||||||
|
max_runtime_seconds=300,
|
||||||
|
goal_mode=True,
|
||||||
|
goal_max_turns=3,
|
||||||
|
)
|
||||||
|
comments: list = []
|
||||||
|
calls: list = []
|
||||||
|
board = _lane_board(tmp_path, task, comments, calls)
|
||||||
|
_isolate_lane(tmp_path, monkeypatch, board)
|
||||||
|
selections: list = []
|
||||||
|
|
||||||
|
def select_route(_prompt, lane, **_kwargs):
|
||||||
|
selections.append(lane)
|
||||||
|
if len(selections) == 1:
|
||||||
|
return _route("codex", "high")
|
||||||
|
raise RuntimeError("Switchyard worker routing failed: refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"run_provider",
|
||||||
|
lambda *_args, **_kwargs: lanes.ProcessResult(
|
||||||
|
0, "turn", dict(COMPLETED_RESULT), False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes.cli_lane_goal,
|
||||||
|
"judge_goal_completion",
|
||||||
|
lambda *_args, **_kwargs: (False, "verification is still missing"),
|
||||||
|
)
|
||||||
|
continued: list = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lanes,
|
||||||
|
"record_route_decision",
|
||||||
|
lambda provider, _effort, _classifier, outcome: continued.append(
|
||||||
|
(provider, outcome)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
lanes.execute_claim("cassandra", "t_goal_router")
|
||||||
|
|
||||||
|
assert selections == ["cli-auto", "cli-auto"]
|
||||||
|
assert ("codex", "goal-continued") in continued
|
||||||
|
kind, kwargs = calls[-1]
|
||||||
|
assert kind == "block" and kwargs["kind"] == "transient"
|
||||||
|
assert "Switchyard route selection is unavailable" in kwargs["reason"]
|
||||||
Loading…
x
Reference in New Issue
Block a user