#!/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())