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

239 lines
8.7 KiB
Python
Raw Normal View History

2026-08-17 08:16:35 -03:00
#!/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.
"""
2026-08-17 08:16:35 -03:00
from __future__ import annotations
import inspect
import json
import os
2026-08-17 08:16:35 -03:00
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
2026-08-17 08:16:35 -03:00
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,
2026-08-17 08:16:35 -03:00
"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)
2026-08-17 08:16:35 -03:00
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())