atlas-iac/services/hermes/scripts/cli_lane_config.py

225 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Shared immutable configuration and result types for Hermes CLI lanes."""
from __future__ import annotations
import os
import re
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
import cli_lane_goal
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
SWITCHYARD_URL = os.environ.get(
"HERMES_SWITCHYARD_URL",
"http://hermes-switchyard.hermes.svc.cluster.local:9005/v1/chat/completions",
)
STATE_ROOT = DATA_ROOT / "cli-lanes"
CODEX_BIN = DATA_ROOT / "tools/bin/codex"
CLAUDE_BIN = Path(os.environ.get("HERMES_CLAUDE_BIN", DATA_ROOT / "tools/bin/claude"))
CLAUDE_SETTINGS = DATA_ROOT / "home/.claude/settings.json"
RESULT_SCHEMA_PATH = STATE_ROOT / "worker-result.schema.json"
EFFORTS = ("low", "medium", "high", "xhigh")
CAPABILITIES = ("economy", "balanced", "advanced", "frontier")
EXTERNAL_PREFIX = "cli-"
DEFAULT_CLAIM_TTL = 7 * 24 * 60 * 60
DEFAULT_MAX_RUNTIME = 12 * 60 * 60
HEARTBEAT_SECONDS = 20
PROVIDER_HEALTH_MAX_AGE_SECONDS = 5 * 60
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS = 12 * 60 * 60
QUOTA_MIN_REMAINING_PERCENT_DEFAULT = 15.0
CAPACITY_COOLDOWN_SECONDS_DEFAULT = 300.0
AUTH_COOLDOWN_SECONDS_DEFAULT = 3600.0
QUOTA_METRICS_URL = os.environ.get(
"HERMES_CLI_QUOTA_METRICS_URL", "http://127.0.0.1:9010/metrics"
)
KANBAN_STORAGE_ATTEMPTS = 5
ARTIFACT_GC_INTERVAL_SECONDS = 5 * 60
ARTIFACT_RETENTION_AGE_SECONDS = 30 * 24 * 60 * 60
ARTIFACT_RETENTION_COUNT = 256
ARTIFACT_RETENTION_BYTES = 128 * 1024 * 1024
MAX_TERMINAL_RECORD_BYTES = 1024 * 1024
QUARANTINE_HASH_BYTES = 64 * 1024
SQLITE_RUN_ID_MAX = 2**63 - 1
PROVIDER_HEALTH_PATHS = {
"codex": DATA_ROOT / "provider-health/codex.json",
"claude": DATA_ROOT / "provider-health/claude.json",
}
WORKTREE_LOCK = threading.Lock()
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
LAST_ARTIFACT_GC = 0.0
CAPACITY_PATTERN = re.compile(
r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529"
r"|authentication|unauthorized|forbidden|oauth|token.*expired|401|403)",
re.I,
)
NO_CLAUDE_SESSION = "No conversation found with session ID:"
CLAUDE_SESSION_COLLISION = "Session ID already in use"
NO_CODEX_THREAD = "no rollout found for thread id"
RESULT_SCHEMA: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"required": [
"status",
"summary",
"changed_files",
"tests_run",
"artifacts",
"findings",
"blockers",
],
"properties": {
"status": {
"type": "string",
"enum": sorted(cli_lane_goal.RESULT_STATUSES),
},
"summary": {"type": "string"},
"changed_files": {"type": "array", "items": {"type": "string"}},
"tests_run": {"type": "array", "items": {"type": "string"}},
"artifacts": {"type": "array", "items": {"type": "string"}},
"findings": {
"type": "array",
"items": {"type": "string"},
"description": (
"Defects, risks, or observations discovered by a review or diagnosis. "
"Findings do not prevent the assigned review or diagnosis from completing."
),
},
"blockers": {
"type": "array",
"items": {"type": "string"},
"description": (
"Concrete obstacles that prevent completion of the assigned task itself. "
"This must be empty when status is completed; review findings belong in findings."
),
},
},
}
@dataclass(frozen=True)
class Route:
"""One provider/model/effort decision for an observable worker boundary."""
provider: str
model: str
effort: str
profile: str
classifier: str
reason: str
latency_ms: int
fallback_chain: tuple[str, ...]
# Capability is intentionally independent from reasoning effort. Keep the
# default for older durable records and positional test fixtures.
capability: str = "advanced"
@dataclass
class ProcessResult:
"""Captured outcome from one provider CLI invocation."""
returncode: int
output: str
structured: dict[str, Any] | None
capacity_failure: bool
class TerminalFinalizationPending(RuntimeError):
"""An accepted worker result is durable but not committed to Kanban yet."""
def canonical_run_id(value: object) -> int | None:
"""Return one positive SQLite-safe run ID from an int or canonical decimal."""
if type(value) is int:
candidate = value
elif type(value) is str:
# Bound conversion before ``int`` so an attacker cannot hand Python an
# arbitrarily large decimal from an otherwise lexically valid name.
if not re.fullmatch(r"[1-9][0-9]{0,18}", value):
return None
candidate = int(value)
else:
return None
if not 1 <= candidate <= SQLITE_RUN_ID_MAX:
return None
return candidate
@dataclass(frozen=True)
class TerminalIdentity:
"""Replay authority derived only from a journal's directory and filename."""
board: str
task_id: str
run_id: int
state: str
def __post_init__(self) -> None:
"""Reject noncanonical identities before they can reach SQLite."""
if type(self.run_id) is not int or canonical_run_id(self.run_id) is None:
raise ValueError("terminal run identity is not a positive SQLite int64")
@dataclass
class TerminalSnapshot:
"""One inode-bound, bounded journal read held open across finalization."""
document: dict[str, Any]
file_stat: os.stat_result
descriptor: int
directory_descriptor: int
def close(self) -> None:
"""Release the pinned file and directory descriptors."""
os.close(self.descriptor)
os.close(self.directory_descriptor)
@dataclass
class TerminalRecoverySnapshot:
"""One raw directory entry pinned before recovery classifies its payload."""
document: dict[str, Any] | None
file_stat: os.stat_result
descriptor: int | None
directory_descriptor: int
prefix: bytes
invalid_reason: str | None
def close(self) -> None:
"""Release the descriptors retained across classification/quarantine."""
if self.descriptor is not None:
os.close(self.descriptor)
self.descriptor = None
if self.directory_descriptor >= 0:
os.close(self.directory_descriptor)
self.directory_descriptor = -1
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def kanban_setting(name: str, default: float) -> float:
"""Read one numeric routing setting from the deployed kanban config block."""
try:
document = yaml.safe_load(
(DATA_ROOT / "config.yaml").read_text(encoding="utf-8")
)
except (OSError, yaml.YAMLError):
return default
kanban = document.get("kanban") if isinstance(document, dict) else None
value = kanban.get(name) if isinstance(kanban, dict) else None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return default
return float(value)