239 lines
8.7 KiB
Python
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())
|