atlas-iac/services/hermes/scripts/cli_lane_capabilities.py
jenkins e3ecc18d0e hermes: refresh capabilities and readiness per loop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:03:50 -03:00

239 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Detect and publish the Kanban API boundary required by CLI lanes.
During a rollout the coordinator container and the cli-lane-runner worker can
run different hermes-agent image generations against one shared Kanban DB.
The image APIs that differ on the decomposition/finalization path:
* ``complete_task``: both generations accept ``expected_run_id`` (active
exact-run completion); only the patched image accepts
``replay_ended_run_id`` (ended-run replay).
* ``reclaim_task``: only the patched image accepts ``expected_run_id``
(guarded orphan reclaim after a runner restart).
* ``specify_triage_task``/``decompose_triage_task``: only the patched image
accepts ``require_no_runs`` (refuses redefining tasks with run history).
Worker-side handling per combination (proven in
testing/tests/test_hermes_cli_capabilities.py):
* legacy worker: health/readiness stay deferred, no new claims, no unguarded
reclaim, replay-needing results stay journaled for a patched successor.
* patched worker: ready health, exact completion, guarded replay/reclaim; a
run voided by a legacy coordinator's unguarded mid-run decomposition
converges to stale/conflict evidence instead of completing.
* patched coordinator: mid-run decomposition is refused entirely, so the
active exact run completes on either worker generation.
"""
from __future__ import annotations
import inspect
import json
import os
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from cli_lane_config import STATE_ROOT, utc_now
from cli_lane_files import atomic_json, load_json
_CAPABILITIES: KanbanCapabilities | None = None
_CAPABILITY_SOURCE: tuple[int, int] | None = None
@dataclass(frozen=True)
class KanbanCapabilities:
"""Safety-relevant keyword support exposed by the image's Kanban DB."""
exact_run_completion: bool
ended_run_replay: bool
exact_run_reclaim: bool
@property
def ready(self) -> bool:
"""Return whether dispatch and every recovery transition are safe."""
return (
self.exact_run_completion
and self.ended_run_replay
and self.exact_run_reclaim
)
@property
def deferred_features(self) -> tuple[str, ...]:
"""List fixed, bounded capability names absent from the image API."""
missing = []
if not self.exact_run_completion:
missing.append("exact-run-completion")
if not self.ended_run_replay:
missing.append("ended-run-replay")
if not self.exact_run_reclaim:
missing.append("exact-run-reclaim")
return tuple(missing)
def _explicit_keyword(function: Callable[..., Any], keyword: str) -> bool:
"""Require an explicit keyword parameter rather than trusting ``**kwargs``."""
try:
parameter = inspect.signature(function).parameters.get(keyword)
except (TypeError, ValueError):
return False
return parameter is not None and parameter.kind in {
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
}
def detect_kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
"""Inspect the loaded image API without invoking a mutating DB operation."""
complete_task = getattr(kanban_db, "complete_task", None)
reclaim_task = getattr(kanban_db, "reclaim_task", None)
return KanbanCapabilities(
exact_run_completion=callable(complete_task)
and _explicit_keyword(complete_task, "expected_run_id"),
ended_run_replay=callable(complete_task)
and _explicit_keyword(complete_task, "replay_ended_run_id"),
exact_run_reclaim=callable(reclaim_task)
and _explicit_keyword(reclaim_task, "expected_run_id"),
)
def _source_identity(kanban_db: Any) -> tuple[int, int]:
"""Bind cached capability evidence to the two inspected callables."""
return (
id(getattr(kanban_db, "complete_task", None)),
id(getattr(kanban_db, "reclaim_task", None)),
)
def _health_document(capabilities: KanbanCapabilities) -> dict[str, Any]:
"""Return a bounded operator-readable compatibility health document."""
return {
"component": "cli-lane-runner",
"schema_version": 1,
"state": "ready" if capabilities.ready else "deferred",
"ready": capabilities.ready,
"active_run_completion_safe": capabilities.exact_run_completion,
"deferred_features": list(capabilities.deferred_features),
"capabilities": asdict(capabilities),
"observed_at": utc_now(),
}
def initialize_kanban_capabilities(
kanban_db: Any,
*,
health_path: Path | None = None,
) -> KanbanCapabilities:
"""Detect startup compatibility and durably expose ready/deferred health."""
global _CAPABILITIES, _CAPABILITY_SOURCE
capabilities = detect_kanban_capabilities(kanban_db)
_CAPABILITIES = capabilities
_CAPABILITY_SOURCE = _source_identity(kanban_db)
destination = health_path or STATE_ROOT / "runtime-health.json"
destination.parent.mkdir(parents=True, exist_ok=True)
atomic_json(destination, _health_document(capabilities), 0o600)
if not capabilities.ready:
print(
"CLI lane safety compatibility deferred; missing image APIs: "
+ ", ".join(capabilities.deferred_features),
file=sys.stderr,
flush=True,
)
return capabilities
def refresh_kanban_capabilities(
kanban_db: Any,
*,
health_path: Path | None = None,
) -> KanbanCapabilities:
"""Reinspect the live callables and refresh bounded readiness evidence.
ConfigMap script updates and image changes are deliberately not imported or
reloaded here. A running process observes only the already-loaded module,
which prevents a half-old/half-new Python module graph. Tests may replace
the two callables to model an image API transition at a loop boundary.
"""
return initialize_kanban_capabilities(kanban_db, health_path=health_path)
def kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
"""Return startup evidence, redetecting only when the loaded API changes."""
if _CAPABILITIES is None or _source_identity(kanban_db) != _CAPABILITY_SOURCE:
return initialize_kanban_capabilities(kanban_db)
return _CAPABILITIES
def runtime_health(path: Path | None = None) -> dict[str, Any]:
"""Read the bounded health document for probes and operator diagnostics."""
document = load_json(path or STATE_ROOT / "runtime-health.json")
return document if isinstance(document, dict) else {}
def readiness_issue(
path: Path | None = None,
*,
now: datetime | None = None,
maximum_age_seconds: float = 60.0,
) -> str | None:
"""Return a bounded readiness reason without exposing health contents."""
destination = path or STATE_ROOT / "runtime-health.json"
try:
document = json.loads(destination.read_text(encoding="utf-8"))
except FileNotFoundError:
return "runtime health is missing"
except (OSError, UnicodeError, json.JSONDecodeError):
return "runtime health is malformed"
if not isinstance(document, dict) or document.get("schema_version") != 1:
return "runtime health is malformed"
if document.get("state") == "deferred" and document.get("ready") is False:
return "runtime compatibility is deferred"
capabilities = document.get("capabilities")
if (
document.get("state") != "ready"
or document.get("ready") is not True
or not isinstance(capabilities, dict)
or any(
capabilities.get(name) is not True
for name in (
"exact_run_completion",
"ended_run_replay",
"exact_run_reclaim",
)
)
):
return "runtime health is malformed"
observed_at = document.get("observed_at")
if not isinstance(observed_at, str):
return "runtime health is malformed"
try:
observed = datetime.fromisoformat(observed_at.replace("Z", "+00:00"))
except ValueError:
return "runtime health is malformed"
if observed.tzinfo is None:
return "runtime health is malformed"
current = now or datetime.now(timezone.utc)
age = (
current.astimezone(timezone.utc) - observed.astimezone(timezone.utc)
).total_seconds()
if age < -5.0 or age > maximum_age_seconds:
return "runtime health is stale"
return None
def main() -> int:
"""Provide the non-mutating Kubernetes readiness command."""
maximum_age = float(os.environ.get("HERMES_CLI_HEALTH_MAX_AGE_SECONDS", "60"))
issue = readiness_issue(maximum_age_seconds=maximum_age)
if issue:
print(issue, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())