Based on PR #15 (fix/hermes-result-decomposition-reliability); stacked on the decomposed cli_lane modules. - cli_lane_quota: soft-exclude a provider from NEW cli-auto work below the remaining-quota threshold (both-below prefers more remaining; fetch failure fails open with a metric). - cli_lane_health: lane now writes provider health (G7) with classified failure reasons splitting the capacity conflation (quota/auth/ rate-limit/transport) and cooldown hysteresis; re-admission only on full cooldown expiry, passed quota reset, or fresh success (G4). - cli_lane_routing: capacity-limited health now excludes a provider (G3); cooldown/reset-aware re-admission. - cli_lane_failover: explicit cli-codex-*/cli-claude-* assignees fail closed as transient instead of switching providers (G5); fallback depth stays bounded at two hosted providers (G1) with effort preserved; Switchyard outages block transient, not capability (G9). - cli_lane_metrics: route-decision/fallback counters, quota and soft-exclusion gauges, pod-local scrape server (G6). - cli_lane_provider: worker env drops ANTHROPIC_API_KEY, CLAUDE_API_KEY, OPENAI_API_KEY, API_SERVER_KEY so no metered path exists (G10). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
5.7 KiB
Python
182 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded, fail-closed cross-provider failover for one capacity boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from cli_lane_board import _board_call
|
|
from cli_lane_config import ProcessResult, Route
|
|
from cli_lane_health import classify_capacity_failure, record_provider_failure
|
|
from cli_lane_metrics import (
|
|
record_provider_fallback,
|
|
record_route_decision,
|
|
record_router_selection_failure,
|
|
)
|
|
from cli_lane_prompt import build_prompt, git_handoff
|
|
from cli_lane_provider import run_provider
|
|
from cli_lane_records import _persist_candidate
|
|
from cli_lane_routing import select_route
|
|
|
|
|
|
@dataclass
|
|
class FailoverOutcome:
|
|
"""Where one capacity boundary left the claim's route and result."""
|
|
|
|
route: Route
|
|
result: ProcessResult
|
|
candidate_file: Path | None
|
|
unavailable_provider: str | None
|
|
blocked: bool
|
|
|
|
|
|
def _block_transient(
|
|
kanban_db: Any, board: str, task_id: str, run_id: Any, reason: str
|
|
) -> None:
|
|
"""Park one card as retryable so it re-enters when providers recover."""
|
|
_board_call(
|
|
kanban_db,
|
|
board,
|
|
lambda fresh: kanban_db.block_task(
|
|
fresh,
|
|
task_id,
|
|
reason=reason,
|
|
kind="transient",
|
|
expected_run_id=run_id,
|
|
),
|
|
)
|
|
|
|
|
|
def _routed_or_blocked(
|
|
kanban_db: Any,
|
|
board: str,
|
|
task_id: str,
|
|
run_id: Any,
|
|
select: Callable[[], Route],
|
|
) -> Route | None:
|
|
"""Turn a Switchyard outage into a transient block, never a capability one."""
|
|
try:
|
|
return select()
|
|
except RuntimeError as error:
|
|
record_router_selection_failure()
|
|
_block_transient(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
"Switchyard route selection is unavailable; the card retries "
|
|
f"when the router recovers: {error}",
|
|
)
|
|
return None
|
|
|
|
|
|
def capacity_failover(
|
|
kanban_db: Any,
|
|
*,
|
|
board: str,
|
|
task_id: str,
|
|
run_id: Any,
|
|
assignee: str,
|
|
comment: Callable[[str], None],
|
|
context: str,
|
|
workspace: Path,
|
|
state: dict[str, Any],
|
|
state_file: Path,
|
|
log_path: Path,
|
|
heartbeat: Callable[[str], bool],
|
|
deadline: float,
|
|
route: Route,
|
|
result: ProcessResult,
|
|
candidate_file: Path | None,
|
|
goal_turn: int,
|
|
) -> FailoverOutcome:
|
|
"""Apply the capacity policy once: record, fail closed, or fail over."""
|
|
failure_class = classify_capacity_failure(result.output)
|
|
record_provider_failure(route.provider, failure_class)
|
|
record_route_decision(
|
|
route.provider, route.effort, route.classifier, "capacity-failure"
|
|
)
|
|
if assignee != "cli-auto":
|
|
# Fail closed: an operator pinned this provider, so a capacity or
|
|
# auth failure must wait for that provider, never switch away.
|
|
_block_transient(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
f"Manually pinned provider {route.provider} hit a "
|
|
f"{failure_class} failure; the explicit {assignee} lane never "
|
|
"switches providers. Retry after recovery or reassign to cli-auto.",
|
|
)
|
|
return FailoverOutcome(route, result, candidate_file, None, True)
|
|
retry_context = (
|
|
context
|
|
+ "\n\nRouting boundary: the first provider failed from capacity/authentication. "
|
|
+ "Select the alternate hosted provider at an appropriate effort."
|
|
)
|
|
alternate = "claude" if route.provider == "codex" else "codex"
|
|
fallback = _routed_or_blocked(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
lambda: select_route(retry_context, f"cli-{alternate}-{route.effort}"),
|
|
)
|
|
if fallback is None:
|
|
return FailoverOutcome(route, result, candidate_file, route.provider, True)
|
|
record_provider_fallback(route.provider, fallback.provider, failure_class)
|
|
comment(
|
|
f"Provider fallback: {route.provider} -> {fallback.provider} "
|
|
f"after a {failure_class} failure; Jetson reclassified the retry boundary.",
|
|
)
|
|
fallback_result = run_provider(
|
|
fallback,
|
|
build_prompt(
|
|
context,
|
|
workspace,
|
|
git_handoff(workspace, result.output),
|
|
),
|
|
workspace,
|
|
state,
|
|
state_file,
|
|
log_path,
|
|
heartbeat,
|
|
max(1, int(deadline - time.monotonic())),
|
|
)
|
|
if fallback_result.capacity_failure:
|
|
# Bounded failover depth: two hosted providers, never a third
|
|
# metered or local path.
|
|
fallback_class = classify_capacity_failure(fallback_result.output)
|
|
record_provider_failure(fallback.provider, fallback_class)
|
|
record_route_decision(
|
|
fallback.provider, fallback.effort, fallback.classifier, "capacity-failure"
|
|
)
|
|
_block_transient(
|
|
kanban_db,
|
|
board,
|
|
task_id,
|
|
run_id,
|
|
"Both hosted providers failed at this boundary "
|
|
f"({route.provider}: {failure_class}; {fallback.provider}: "
|
|
f"{fallback_class}); the lane never falls to a metered or "
|
|
"local path. Waiting for provider recovery.",
|
|
)
|
|
return FailoverOutcome(
|
|
fallback, fallback_result, candidate_file, route.provider, True
|
|
)
|
|
if fallback_result.structured:
|
|
candidate_file = _persist_candidate(
|
|
state,
|
|
state_file,
|
|
dict(fallback_result.structured),
|
|
route=fallback,
|
|
returncode=fallback_result.returncode,
|
|
goal_turn=goal_turn,
|
|
)
|
|
return FailoverOutcome(
|
|
fallback, fallback_result, candidate_file, route.provider, False
|
|
)
|