248 lines
7.8 KiB
Python
248 lines
7.8 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 = 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")
|
|
EXTERNAL_PREFIX = "cli-"
|
|
DEFAULT_MAX_RUNTIME = 12 * 60 * 60
|
|
HEARTBEAT_SECONDS = 20
|
|
DIRECT_CLAIM_LOCK = "direct-cli-lane"
|
|
DEFAULT_DIRECT_HEARTBEAT_TIMEOUT_SECONDS = 10 * 60
|
|
DIRECT_CLAIM_TTL_GRACE_SECONDS = 60
|
|
# Compatibility constant for callers that only need the default. Direct-lane
|
|
# claims use ``direct_claim_ttl_seconds`` so deployed config is re-read.
|
|
DEFAULT_CLAIM_TTL = (
|
|
DEFAULT_DIRECT_HEARTBEAT_TIMEOUT_SECONDS + DIRECT_CLAIM_TTL_GRACE_SECONDS
|
|
)
|
|
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, ...]
|
|
|
|
|
|
@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)
|
|
|
|
|
|
def direct_heartbeat_timeout_seconds() -> int:
|
|
"""Return the bounded no-heartbeat window for a direct CLI run."""
|
|
configured = int(
|
|
kanban_setting(
|
|
"direct_lane_heartbeat_timeout_seconds",
|
|
DEFAULT_DIRECT_HEARTBEAT_TIMEOUT_SECONDS,
|
|
)
|
|
)
|
|
if configured <= 0:
|
|
configured = DEFAULT_DIRECT_HEARTBEAT_TIMEOUT_SECONDS
|
|
# Three missed 20s heartbeats is the smallest useful death window. This
|
|
# also prevents an accidental tiny config value from causing churn.
|
|
return max(HEARTBEAT_SECONDS * 3, configured)
|
|
|
|
|
|
def direct_claim_ttl_seconds() -> int:
|
|
"""Keep claim expiry just beyond the independent heartbeat watchdog."""
|
|
return direct_heartbeat_timeout_seconds() + DIRECT_CLAIM_TTL_GRACE_SECONDS
|